1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech
//! `GridBodyPane<T>` — the virtualized tile pane.
//!
//! Like `TableView`'s `BodyPane`, this is a **sibling** of the scrollbar
//! rather than its ancestor, so rebuilds triggered by scroll-buffer exits
//! or column-count changes don't tear down the scrollbar mid-thumb-drag
//! (the framework's rebuild deferral only skips rebuilds targeting an
//! *ancestor* of the captured widget). `GridView` owns the pane, the
//! optional scrollbar, and the `GridOverlay` as three flat children.
//!
//! The pane realizes only the tiles in the strategy's visible range (plus
//! buffer), positions them at `tile_rect - scroll`, and — for
//! variable-height strategies — measures each realized tile and feeds the
//! heights back for scroll-anchoring (see [`GridLayoutStrategy::observe_measured`]).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, PointerButton, WidgetEvent};
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_data::{DragEligibility, RowState, SelectionModel};
use super::TileContext;
use super::a11y::TileA11y;
use super::layout::GridLayoutStrategy;
use crate::data_views::{RowSelection, ViewId, default_placeholder};
pub(crate) type LenFn = Rc<dyn Fn() -> usize>;
pub(crate) type WithItemFn<T> =
Rc<dyn Fn(usize, &dyn Fn(&T) -> Box<dyn Widget>) -> Option<Box<dyn Widget>>>;
/// Read `&T` from the resident row at `index` via a side-effecting callback,
/// returning whether it ran (row present + loaded). Powers export
/// item-cloning (`.exportable(..)`) without the delegate's widget-building
/// path.
pub(crate) type ReadItemFn<T> = Rc<dyn Fn(usize, &mut dyn FnMut(&T)) -> bool>;
pub(crate) type TileDelegate<T> = Rc<dyn Fn(&TileContext<'_, T>) -> Box<dyn Widget>>;
/// Per-tile transferable gate (source-owned): may this tile begin a drag?
pub(crate) type DragFn = Rc<dyn Fn(usize) -> DragEligibility>;
/// Per-tile residency state (source-owned): is this tile loaded, or a
/// windowed placeholder?
pub(crate) type RowStateFn = Rc<dyn Fn(usize) -> RowState>;
/// Nudge the source to load a visible range / append the next page.
pub(crate) type RequestWindowFn = Rc<dyn Fn(std::ops::Range<usize>)>;
pub(crate) type CanFetchMoreFn = Rc<dyn Fn() -> bool>;
pub(crate) type FetchMoreFn = Rc<dyn Fn()>;
/// How close (in tiles) the realized window's end must come to the total
/// before an append-only source is asked to `fetch_more`.
const FETCH_BUFFER_TILES: usize = 24;
pub(crate) struct GridBodyPane<T: 'static> {
pub(crate) len_fn: LenFn,
pub(crate) with_item_fn: WithItemFn<T>,
pub(crate) delegate: TileDelegate<T>,
pub(crate) strategy: Rc<dyn GridLayoutStrategy>,
pub(crate) viewport_width: Rc<Cell<f32>>,
pub(crate) viewport_height: Rc<Cell<f32>>,
/// The pane's absolute (window) origin, published each `place_children`
/// pass so `GridView`'s keyboard handler can chase the focused tile into
/// enclosing scroll areas. Shares the `GridView`'s `viewport_origin` cell
/// (`None` until this pane lays out at least once).
pub(crate) viewport_origin: Rc<Cell<Option<teksilo_canvas::Point>>>,
/// Live column count, written by `GridView::place_children`. Drives a
/// rebuild when the window resize changes how many columns fit.
pub(crate) column_count: Signal<usize>,
pub(crate) scroll_y: Signal<f32>,
pub(crate) selection: Option<SelectionModel>,
/// Read-only snapshot for `TileContext::is_focused`. The pane does NOT
/// rebuild on focus change — the focus ring is painted by `GridOverlay`.
pub(crate) focused_index: Signal<Option<usize>>,
// Per-tile interaction callbacks (Phase 3).
#[allow(clippy::type_complexity)]
pub(crate) on_tile_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
pub(crate) activate_on: crate::data_views::ActivateOn,
#[allow(clippy::type_complexity)]
pub(crate) tile_context_menu: Option<
Rc<
dyn Fn(
usize,
teksilo_canvas::Point,
&mut teksilo_core::widget::EventContext,
) -> Option<Box<dyn Widget>>,
>,
>,
/// Per-tile accessible name for the `GridCell` (see `GridView::tile_a11y_label`).
#[allow(clippy::type_complexity)]
pub(crate) tile_a11y_label: Option<Rc<dyn Fn(usize) -> String>>,
pub(crate) reorderable: bool,
pub(crate) model_id: ViewId,
/// Cross-widget export / foreign-receive machinery, shared with
/// `GridView` (the single source of truth for the setting) — the
/// drag-start payload build and the move-out completion.
pub(crate) export: crate::data_views::RowExport<T>,
/// Read `&T` from the resident row at `index` (source-owned), used to
/// clone dragged items for export.
pub(crate) read_item_fn: ReadItemFn<T>,
/// Stable-key removal thunk resolver for the default move-out
/// (source-owned), invoked at drag-start via `RowExport::build_payload`.
pub(crate) snapshot_out_fn: crate::data_views::SnapshotOutFn,
/// The GridView root's focusable `WidgetId`. The focus scope this pane
/// opens for its tiles is keyed on the root (where keyboard focus lands),
/// not on the pane itself (a non-focusable child), so a `StandardItem`
/// tile's focus-aware selection tracks the grid's real focus.
pub(crate) scope_owner: WidgetId,
/// Source-owned DnD + lazy capability closures (erased from the backing
/// `ListDataSource`). `drag_fn` gates per-tile drag start; `row_state_fn`
/// drives windowed placeholders; the lazy trio nudges the source to load
/// the realized window / fetch the next page as the viewport advances.
pub(crate) drag_fn: DragFn,
pub(crate) row_state_fn: RowStateFn,
pub(crate) request_window_fn: RequestWindowFn,
pub(crate) can_fetch_more_fn: CanFetchMoreFn,
pub(crate) fetch_more_fn: FetchMoreFn,
/// Shared (flat index → tile wrapper id) map, written at the end of
/// each build for the container's `active_descendant` roving focus.
pub(crate) tile_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
// Section headers (Phase 4). When set, the pane realizes a header widget
// per visible section alongside the tiles.
#[allow(clippy::type_complexity)]
pub(crate) header_factory: Option<Rc<dyn Fn(usize) -> Box<dyn Widget>>>,
#[allow(clippy::type_complexity)]
pub(crate) header_title: Option<Rc<dyn Fn(usize) -> String>>,
/// Pane-local rebuild trigger. A persistent field (re-bound each
/// build) so `place_children`'s post-measure realization re-check
/// can request a rebuild of this pane.
pub(crate) version: Signal<u64>,
/// Bound at `Relayout` on the `GridView` ROOT — bumped when a
/// measure pass changes the content total so the root re-places
/// with the corrected `max_scroll_y` / thumb ratio next frame (the
/// root computes them before this pane measures; without the poke
/// they'd stay stale until the next scroll).
pub(crate) total_refresh: Signal<u64>,
/// Realized tile range from the latest build — consulted by both
/// the scroll observer and the realization re-check.
pub(crate) prev_built_start: Rc<Cell<usize>>,
pub(crate) prev_built_end: Rc<Cell<usize>>,
// Build state
pub(crate) tile_entries: Vec<(usize, WidgetId)>,
pub(crate) header_entries: Vec<(usize, WidgetId)>,
/// Re-entrancy guard for `place_children`'s Relayout-bound signal
/// writes (`scroll_y`, `version`, `total_refresh`) — see the comment
/// at their call site. Not `Rc`-shared: purely internal bookkeeping
/// for this one pane instance.
pub(crate) in_place_children: Cell<bool>,
}
impl<T: 'static> GridBodyPane<T> {
fn visible(&self) -> (usize, usize) {
let count = (self.len_fn)();
let vr = self.strategy.visible_range(
self.scroll_y.get(),
self.viewport_height.get(),
self.viewport_width.get(),
count,
);
(vr.start, vr.end)
}
}
impl<T: 'static> std::fmt::Debug for GridBodyPane<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GridBodyPane")
.field("items", &(self.len_fn)())
.field("realized", &self.tile_entries.len())
.finish()
}
}
impl<T: 'static> Widget for GridBodyPane<T> {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
// Self-rebuild trigger. A persistent field (not `ctx.signal`)
// so the realization re-check in `place_children` can bump it
// after measurement.
let version = self.version.clone();
version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
// Scroll re-places tiles without rebuilding (within buffer).
self.scroll_y.bind_to(
ctx.self_id(),
ctx.binding_registry(),
BindingLevel::Relayout,
);
ctx.register_animated_signal(&self.scroll_y);
// Buffer-exit detection → rebuild THIS pane (sibling of scrollbar).
let strategy = self.strategy.clone();
let len = self.len_fn.clone();
let vp_h = self.viewport_height.clone();
let vp_w = self.viewport_width.clone();
let (initial_start, initial_end) = self.visible();
self.prev_built_start.set(initial_start);
self.prev_built_end.set(initial_end);
let v_scroll = version.clone();
let scroll_handle = self.scroll_y.observe({
let ps = self.prev_built_start.clone();
let pe = self.prev_built_end.clone();
move |y| {
let vr = strategy.visible_range(*y, vp_h.get(), vp_w.get(), (len)());
if vr.start < ps.get() || vr.end > pe.get() {
ps.set(vr.start);
pe.set(vr.end);
v_scroll.set(v_scroll.get() + 1);
}
}
});
ctx.own_handle(scroll_handle);
// Column-count change (window resize reflow) → rebuild.
{
let v = version.clone();
let counter = Rc::new(Cell::new(0_u64));
ctx.effect(&self.column_count, move |_| {
counter.set(counter.get() + 1);
v.set(counter.get());
});
}
// Selection change → re-render visible tiles (refresh `is_selected`).
if let Some(ref sel) = self.selection {
let v = version.clone();
let counter = Rc::new(Cell::new(0_u64));
ctx.effect(&sel.selection_signal(), move |_| {
counter.set(counter.get() + 1);
v.set(counter.get());
});
}
// Export completion (move-out): fires on the drag source — THIS
// pane's own id, the stable id `start_drag` is anchored on below. A
// same-view reorder is applied by `GridView`'s root `on_drop`, which
// calls `note_self_reorder`, so it is skipped here (already applied).
ctx.apply_self_handlers(self.export.install_completion(HandlerSet::new()));
// Realize the visible tiles.
self.tile_entries.clear();
let total = (self.len_fn)();
let (start, end) = self.visible();
// Lazy: nudge the source to load the realized window, and fetch more
// as the viewport nears the end (append-only sources). Fires on every
// pane build — i.e. on each scroll-buffer exit — matching ListView.
(self.request_window_fn)(start..end);
if (self.can_fetch_more_fn)() && end + FETCH_BUFFER_TILES >= total {
(self.fetch_more_fn)();
}
let focused = self.focused_index.get();
// Built ONCE per pane build (not per tile) and cheaply `Clone`d
// per-tile below — the facade's `Rc<dyn Fn>` closures would be
// real allocations if constructed inside the realize loop.
let sel_facade = self
.selection
.as_ref()
.map(|s| RowSelection::from_index(s.clone()));
ctx.begin_view_focus_for(self.scope_owner);
for i in start..end {
// Per-strategy: global row-major math for uniform/variable-row/
// waterfall, section-local for a sectioned grid (each section
// starts its own row band — see `SectionedGrid::tile_row_col`).
let (row, col) = self.strategy.tile_row_col(i, self.viewport_width.get());
let selected = self
.selection
.as_ref()
.map(|s| s.is_selected(i))
.unwrap_or(false);
let is_focused = focused == Some(i);
let delegate = self.delegate.clone();
// A `Loading` tile (data not yet resident) renders a placeholder
// skeleton instead of being skipped, so the scrollbar and layout
// stay stable while the window loads.
let widget = (self.with_item_fn)(i, &|item| {
let tc = TileContext {
index: i,
row,
col,
item,
is_selected: selected,
is_focused,
};
delegate(&tc)
})
.or_else(|| ((self.row_state_fn)(i) == RowState::Loading).then(default_placeholder));
let Some(widget) = widget else { continue };
let inner_id = ctx.add_boxed(widget);
let a11y_name = self.tile_a11y_label.as_ref().map(|f| f(i));
let tile_id = ctx.add(TileA11y::new(
inner_id,
row + 1,
col + 1,
i + 1,
total,
selected,
a11y_name,
));
// Selection click. Returns Ignored so the gesture arena still
// sees the PointerDown (drag-to-reorder / marquee). Deferred
// collapse: pressing an already-selected tile (no modifiers)
// keeps the whole (multi-)selection so it can be dragged; the
// collapse-to-single happens on release WITHOUT a drag. The
// press-claimed guard, Ctrl/Shift handling, and the defer rule
// itself live in the shared `deferred_select` helper (mirrors
// `ListView` / `TreeView`); only the focus-follows-selection
// step is grid-specific, gated on `on_down`'s return.
if let Some(ref sel) = sel_facade {
let sel_click = sel.clone();
let focused_set = self.focused_index.clone();
let idx = i;
let pending_collapse = Rc::new(Cell::new(false));
ctx.apply_handlers(
tile_id,
HandlerSet::new().on_pointer_event(move |event, ctx| match event {
WidgetEvent::PointerDown {
button: PointerButton::Primary,
modifiers,
..
} => {
if crate::data_views::deferred_select::on_down(
&sel_click,
idx,
*modifiers,
&pending_collapse,
ctx,
) {
focused_set.set(Some(idx));
}
EventResponse::Ignored
}
WidgetEvent::PointerUp {
button: PointerButton::Primary,
..
} => {
crate::data_views::deferred_select::on_up(
&sel_click,
idx,
&pending_collapse,
ctx,
);
EventResponse::Ignored
}
_ => EventResponse::Ignored,
}),
);
}
// Activation (double-tap), context menu, drag-to-reorder, and
// the AT click. `extra` is always applied — every tile carries
// the access-action handler below.
let mut extra = HandlerSet::new();
if let Some(cb) = &self.on_tile_activate {
let cb = cb.clone();
let idx = i;
extra = match self.activate_on {
crate::data_views::ActivateOn::SingleClick => {
extra.on_tap(move |_tap, ctx| cb(idx, ctx))
}
crate::data_views::ActivateOn::DoubleClick => {
extra.on_double_tap(move |_tap, ctx| cb(idx, ctx))
}
};
}
if let Some(factory) = &self.tile_context_menu {
let factory = factory.clone();
let idx = i;
extra = extra.context_menu(move |pos, ctx| factory(idx, pos, ctx));
}
if self.export.is_drag_source(self.reorderable) {
let idx = i;
let model_id = self.model_id;
let anchor = ctx.self_id();
let delegate = self.delegate.clone();
let with_item = self.with_item_fn.clone();
let strategy = self.strategy.clone();
let vp_w = self.viewport_width.clone();
let drag_gate = self.drag_fn.clone();
// Export capture: the dragged set is selection-aware; the
// shared `RowExport` builds the payload (clones / MIME /
// Loading-filter / stash) when the view opted in.
let sel_for_drag = self.selection.clone();
let export_for_drag = self.export.clone();
let read_for_drag = self.read_item_fn.clone();
let snapshot_for_drag = self.snapshot_out_fn.clone();
extra = extra.on_drag(move |phase, ctx| {
if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
// The source's per-tile transferable gate.
if (drag_gate)(idx) == DragEligibility::NoDrag {
return;
}
// Selection-aware dragged set: the whole selection
// when the pressed tile is part of a
// multi-selection, else just the pressed tile.
let rows: Vec<usize> = match sel_for_drag.as_ref() {
Some(s) if s.is_selected(idx) => {
let mut v = s.selected_indices();
v.sort_unstable();
if v.len() <= 1 { vec![idx] } else { v }
}
_ => vec![idx],
};
let Some(payload) = export_for_drag.build_payload(
model_id,
rows,
&*read_for_drag,
&snapshot_for_drag,
) else {
return;
};
let r = strategy.tile_rect(idx, vp_w.get());
let (w, h) = (r.width.max(40.0), r.height.max(40.0));
let delegate = delegate.clone();
let (row, col) = strategy.tile_row_col(idx, vp_w.get());
let preview = (with_item)(idx, &|item| {
let tc = TileContext {
index: idx,
row,
col,
item,
is_selected: false,
is_focused: false,
};
Box::new(crate::drag_preview::DragPreview::new(w, h, delegate(&tc)))
as Box<dyn Widget>
});
if let Some(preview) = preview {
ctx.start_drag_with_preview(anchor, payload, preview);
} else {
ctx.start_drag(anchor, payload);
}
}
});
}
// AT / automation `Action::Click`. `TileA11y` advertises it,
// but every pointer handler above is `on_pointer_event` /
// `on_tap` — and the dispatcher never synthesizes a tap from
// an access action, so a tile is otherwise undriveable by
// assistive tech. AccessKit defines `Click` as "the
// equivalent of a single click or tap", and the Windows /
// macOS adapters also map AT *select-this-item* on a
// selectable node (this one calls `set_selected`) to `Click`
// — so select, and activate only when a single click would.
{
let sel = self.selection.clone();
let focused_set = self.focused_index.clone();
let activate = self.on_tile_activate.clone();
let activate_on = self.activate_on;
let idx = i;
extra = extra.on_access_action(move |action, ctx| {
if action != teksilo_core::accesskit::Action::Click {
return EventResponse::Ignored;
}
focused_set.set(Some(idx));
if let Some(sel) = sel.as_ref() {
sel.select(idx);
}
if activate_on == crate::data_views::ActivateOn::SingleClick
&& let Some(cb) = activate.as_ref()
{
cb(idx, ctx);
}
EventResponse::Handled
});
}
ctx.apply_handlers(tile_id, extra);
self.tile_entries.push((i, tile_id));
}
ctx.end_view_focus();
// Realize the visible section headers.
self.header_entries.clear();
if let Some(factory) = &self.header_factory {
let headers = self.strategy.headers_in_range(
self.scroll_y.get(),
self.viewport_height.get(),
self.viewport_width.get(),
);
for (section, _rect) in headers {
let body = factory(section);
let inner = ctx.add_boxed(body);
let title = self
.header_title
.as_ref()
.map(|f| f(section))
.unwrap_or_default();
let hid = ctx.add(super::a11y::SectionHeaderA11y::new(inner, title));
self.header_entries.push((section, hid));
}
}
*self.tile_map.borrow_mut() = self.tile_entries.clone();
let mut ids: Vec<WidgetId> = self.tile_entries.iter().map(|(_, id)| *id).collect();
ids.extend(self.header_entries.iter().map(|(_, id)| *id));
ids
}
fn layout_response(
&self,
proposal: SizeProposal,
_ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
// Only an allocation may seed the cached viewport — a measurement's
// fallback would desync `build`'s realization window (`common::viewport`).
crate::common::viewport::viewport_size(
proposal,
&self.viewport_height,
Size::new(400.0, 300.0),
)
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
// Debug-only re-entrancy guard for the Relayout-bound signal writes
// below (`scroll_y`'s anchor correction, `version`, `total_refresh`
// — see the comments at each call site). Their safety rests on an
// invariant this function cannot observe directly: `WidgetTree`
// flushes every pending relayout-dirty mark exactly ONCE per
// `layout()` / `layout_with_ops()` call, *before* the recursive
// `place_children` walk begins — so a `Signal::set` here only
// marks a consumer dirty for the NEXT pass, never triggers a
// synchronous nested layout within this one. If that ever stopped
// holding, this pane would be re-entered before this call
// returns, and the flag below turns that into a diagnostic panic
// instead of a silent infinite bounce or a stack overflow.
//
// This only catches a *synchronous* re-entry on this exact pane
// instance — there's no pass/frame id on `LayoutContext` for
// `place_children` to compare against, so a same-frame-but-not-
// nested double call (were the walk order ever restructured to
// revisit a dirtied subtree before returning to the caller) would
// slip past it. `Signal::try_set`'s own debug-only
// `NotifyDepthGuard` remains the general backstop for a runaway
// observer feedback loop through these signals.
debug_assert!(
!self.in_place_children.get(),
"GridBodyPane::place_children was re-entered — the single- \
flush-per-pass invariant its scroll-anchor/total-refresh \
signal writes rely on no longer holds"
);
self.in_place_children.set(true);
// The allocated height is the authoritative viewport: `build` sizes its
// realization window from this, and a stale value there costs a
// permanent rebuild loop (`common::viewport`).
crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
// Publish our absolute origin so GridView's keyboard handler can build
// the focused tile's window rect for the outer-scroll chase.
self.viewport_origin
.set(Some(teksilo_canvas::Point::new(bounds.x, bounds.y)));
let scroll_y = self.scroll_y.get();
let vp_w = bounds.width;
let measures = self.strategy.measures_tiles();
// Lookups: tile id → model index, header id → section.
let tile_of: std::collections::HashMap<WidgetId, usize> = self
.tile_entries
.iter()
.map(|(idx, id)| (*id, *idx))
.collect();
let header_of: std::collections::HashMap<WidgetId, usize> = self
.header_entries
.iter()
.map(|(s, id)| (*id, *s))
.collect();
// Pass A — measure realized tiles (variable-height strategies only).
let pre_total = if measures {
self.strategy.total_content_height((self.len_fn)(), vp_w)
} else {
0.0
};
let mut measured: Vec<(usize, f32)> = Vec::new();
if measures {
measured.reserve(self.tile_entries.len());
for child in children.iter() {
if let Some(&model_index) = tile_of.get(&child.id) {
let r = self.strategy.tile_rect(model_index, vp_w);
let h = ctx
.child_size(child.id, SizeProposal::with_width(r.width))
.map(|s| s.height)
.unwrap_or(r.height);
measured.push((model_index, h));
}
}
}
// Feed measurements back; the strategy updates its height cache and
// returns the scroll-anchor correction.
let anchor_delta = if measures {
self.strategy.observe_measured(&measured, scroll_y, vp_w)
} else {
0.0
};
// O(1) per-tile height lookup for Pass B (place_children runs every
// scroll frame — a linear scan here would be O(tiles²)).
let measured_h: std::collections::HashMap<usize, f32> = measured.into_iter().collect();
// Pass B — place tiles and headers using the (possibly updated) rects.
for child in children.iter_mut() {
if let Some(&model_index) = tile_of.get(&child.id) {
let r = self.strategy.tile_rect(model_index, vp_w);
let h = if measures {
measured_h.get(&model_index).copied().unwrap_or(r.height)
} else {
r.height
};
child.origin = Point::new(bounds.x + r.x, bounds.y + r.y - scroll_y);
child.size = Size::new(r.width, h);
} else if let Some(§ion) = header_of.get(&child.id) {
if let Some(r) = self.strategy.header_rect(section, vp_w) {
child.origin = Point::new(bounds.x + r.x, bounds.y + r.y - scroll_y);
child.size = Size::new(r.width, r.height);
}
}
}
// Apply scroll-anchor correction — see the invariant documented
// on the re-entrancy guard at the top of this function.
if anchor_delta.abs() > 0.01 {
let new_scroll = (self.scroll_y.get() + anchor_delta).max(0.0);
self.scroll_y.set(new_scroll);
}
// Realization re-check: corrected offsets may reveal viewport
// tiles the estimated offsets never realized (tiles measured
// shorter than the estimate previously left a gap at the bottom
// until the next scroll). Request a pane rebuild for next
// frame; the strategies' sub-pixel measurement epsilon
// guarantees convergence.
if measures {
let len = (self.len_fn)();
let vr = self.strategy.visible_range(
self.scroll_y.get(),
self.viewport_height.get(),
vp_w,
len,
);
if vr.start < self.prev_built_start.get() || vr.end > self.prev_built_end.get() {
self.prev_built_start.set(vr.start);
self.prev_built_end.set(vr.end);
self.version.set(self.version.get() + 1);
}
// Total-refresh poke: the root computed `max_scroll_y` /
// thumb ratio BEFORE this measure pass. Re-place it next
// frame when the total changed — otherwise content past the
// estimated total stays unreachable until the next scroll.
let post_total = self.strategy.total_content_height(len, vp_w);
if (post_total - pre_total).abs() > 0.01 {
self.total_refresh.set(self.total_refresh.get() + 1);
}
}
self.in_place_children.set(false);
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
// A non-hidden generic group between the `Role::Grid` container and
// the `Role::GridCell` tiles keeps the AT path well-formed.
builder.set_role(teksilo_core::accesskit::Role::Group);
}
fn children(&self) -> Vec<WidgetId> {
let mut ids: Vec<WidgetId> = self.tile_entries.iter().map(|(_, id)| *id).collect();
ids.extend(self.header_entries.iter().map(|(_, id)| *id));
ids
}
fn clips_children(&self) -> bool {
true
}
}