Skip to main content

Module grid_view

Module grid_view 

Source
Expand description

Virtualized 2D tile grid bound to a ListModel<T> / ListDataSource.

GridView is the photo-gallery / icon-view / file-manager-grid / collection-view widget — the 2D sibling of ListView and TableView. It realizes only the tiles currently visible (plus a buffer), reflows on resize, supports single / multi selection with 2D keyboard navigation, and is fully accessible (Role::Grid → Role::GridCell).

The layout is pluggable via GridLayoutStrategy: the stock UniformGrid gives fixed tile size / fixed column count / adaptive min-width grids, VariableRowGrid sizes each row to its tallest tile, and VirtualizedMasonry flows items into the currently-shortest column. Marquee selection, drag-reorder, sections and sticky headers are layered over whichever one is in force.

ⓘ
GridView::new(model, |tc| {
    Box::new(Card::new().child(TextWidget::new(lit!(&tc.item.name))))
})
.sizing(GridSizing::Adaptive { min_width: 120.0, max_width: None, height: 140.0 })
.spacing(8.0)
.selection(selection_model)

§Column bounds and alignment

GridView::min_columns and GridView::max_columns bound the column count that GridSizing::Fixed and GridSizing::Adaptive derive from the width (GridSizing::FixedColumnCount ignores both). A floor the width cannot afford narrows the tiles to share it, never overflowing horizontally; the tile height stays the sizing’s. GridView::column_alignment places a block of columns narrower than the content at the leading edge (the default), the centre or the trailing edge, swapping sides in a right-to-left layout. Section headers and the detail band keep spanning the content width; tiles, the rubber band, the insertion bar and the focus ring follow the offset. An album grid of fixed 182 dp cards, two to ten columns, centred:

ⓘ
GridView::new(albums, |tc| album_card(tc))
    .sizing(GridSizing::Fixed { width: 182.0, height: 230.0 })
    .min_columns(2)
    .max_columns(10)
    .column_alignment(HAlignment::Center)

§Selection by position or by key

GridView::selection takes a SelectionModel, which holds positions. A grid that is sorted or filtered under its selection, or fed by a lazy source, is built with GridView::from_source_keyed instead: a KeyedSelectionModel holds the source’s keys, so the selection stays on the same items when they move. Both reach the grid through the facade the other data views use (data_views::RowSelection), which turns each click, key and rubber band into an index or a key.

§The detail band

GridView::detail_row gives a tile a full-width band that opens directly under the row holding it, the “album expansion” of a music library. The band is not a tile and not a row of the grid: a layout strategy wrapped around the grid’s own (detail::DetailRowStrategy) moves everything below the open row down by the band, so virtualization, the scroll range, the focus ring and the rubber band account for it without knowing about it, and tiles keep their row and column numbers. The band widget is a child of this root rather than of the body pane, so it survives the pane’s rebuilds on scroll and resize, and a lazy source loading a window. A waterfall has no rows and ignores it. See the module docs of grid_view/detail.rs.

Focus inside the band is revealed like focus anywhere: this root answers the framework’s WidgetEvent::ScrollIntoView, which a scroll container is sent when a descendant it clips takes focus.

§Scrolling from outside

GridView::handle returns a GridViewHandle, a cheap clone usable before the grid is mounted and through all its rebuilds: scroll_to_index, ensure_index_visible and reveal_band (a tile and its open band together). A handle computes no offset when it is called: it records the request, and the next layout applies it once the band is measured and the scroll anchoring has run. Opening a band and scrolling to a tile under it in one turn therefore lands the tile where it was asked to be, which an offset written from outside does not: the layout keeps the content under the top edge still across the band opening, and takes that offset for one of the old layout. The builder’s own scroll_to_index and ensure_index_visible act at call time, so before the first layout they do nothing.

ⓘ
let handle = grid.handle();
// later, from a command:
expanded.set(Some(album));
handle.reveal_band(album);

§The keyboard cursor

The grid has one current tile, its keyboard cursor: the tile the arrow keys step from, the one the focus ring marks while the grid holds keyboard focus, and the one it names to assistive technology as its active descendant. The same GridViewHandle reads and moves it: cursor, set_cursor (clamped to the last tile; keyboard focus, the selection and the scroll offset are left alone) and cursor_signal, which an application observes to key a status line or its context actions on the current tile. Like a reveal, a cursor set before the grid is mounted is there at its first build.

The cursor and the selection are one tile in a single selection: the keys move them together, and a selection written from outside moves the cursor onto it. In a multiple selection they are separate: Ctrl+Arrow moves the cursor and leaves the selection, so after →, Ctrl+→, Ctrl+↓ the selection is still tile 0 while the cursor, the ring and the active descendant are on tile 4 (in three columns), and a selection written from outside leaves the cursor where it was. Read the cursor, not the first selected tile, for “the tile the user is on”.

An open detail band is on the keyboard path already: ↓ on the open tile moves focus into its band, onto its first control, while the cursor stays on the tile, and ↑ from the band’s top comes back to it. A band with no control to focus is stepped over. An application has no walk of its own to write.

§A long band

A band that holds hundreds of rows, an album’s tracks say, need not build them all. GridView::detail_visible_span_signal publishes the part of the open band on screen, in band-local coordinates, and only when it changes, so the band can realize the rows in the span and stand spacers in for the rest, with no copy of the grid’s placement arithmetic:

ⓘ
const ROW: f32 = 24.0;
let grid = GridView::new(albums, album_card);
let span = grid.detail_visible_span_signal().clone();
grid.detail_row(move |tc| {
    let n = tc.item.tracks.len();
    let first = span.map(|s| s.as_ref().map_or(0, |r| (r.start / ROW) as usize));
    let last = span.map(move |s| {
        s.as_ref().map_or(0, |r| ((r.end / ROW).ceil() as usize).min(n))
    });
    // `window` holds tracks[first..last], refilled when the span moves.
    let window = track_window(&tc.item.tracks, &first, &last);
    Some(Box::new(
        VStack::new()
            .child(FixedSize::new().height(first.map(|f| *f as f32 * ROW)))
            .child(Repeater::new(window, |track| track_row(track)))
            .child(FixedSize::new().height(last.map(move |l| (n - *l) as f32 * ROW))),
    ))
})

§Pan to scroll

The view installs common::scrollable::ScrollableBehavior, which gives it the shared wheel arithmetic, a finger’s pan and the PanClaim that puts it on a pan’s claimant chain. A pan scrolls it, the release coasts, and a pan it cannot absorb hands the whole event to the container outside — never a residual. Vertical only, despite the grid: this view owns no horizontal offset, so a horizontal pan is declined and chains outward. A pan that starts on a tile scrolls rather than activating it.

§The rubber band, and why it is not on this node

In teksilo_data::SelectionMode::Multi a drag on the empty background sweeps a selection rectangle. That drag deliberately does not live on this view’s own node, which is the one carrying the PanClaim.

The reason it was structural has since been fixed in the framework: a node that both declares a claim and carries on_drag is now given a say through PointerSequence::defer_own_drag, which resolves its [teksilo_tokens::DragActivation] against the claim it is competing with. Before that arm existed the claim holder’s own drag latched at drag_slop and decided the sequence before the claim could win at pan_slop, so a Multi-selection grid did not scroll under a finger from anywhere at all, and a finger on the background swept a band immediately rather than after a hold.

What the surface still buys is scope, and that is why it stays. This root’s children are the body pane, the scrollbar, the pinned section header, the empty view, the loading view and the focus overlay — all siblings, most of them filling the same rectangle. A marquee hung here would be a drag ancestor of every one of them; hung on a DragSurface that encloses the body pane alone, it sweeps the background and nothing else. The wrapper is also not this view’s to remove in any case: the four row views wrap each row in one, and a row is not a claimant, so defer_own_drag — which refuses anything but a live MemberRole::Pan member — cannot reach it. See crate::data_views::DragSurface.

So the body pane carries a no-op tap that gives it an arena of its own (the press is captured inside the surface, not by it) and the marquee’s drag hangs on a DragSurface that strictly encloses the pane — the shape the tree arms DragActivation for through its ancestor walk, which is what makes the marquee wait for a long press under a finger and latch at 5 dp under a mouse.

Every tile carries that same no-op tap as well, for a reason with nothing to do with dragging. With the pane holding one, a tile without an arena of its own leaves the pane as the press captor — and a release is dispatched to the captor and then bubbled target→root, which never reaches a tile beneath it. A plain selectable grid lost both its finger tap and, under a mouse, the release that collapses a multi-selection that way.

Re-exports§

pub use sections::SectionProvider as GridSectionProvider;
pub use sections::GroupingSections;
pub use sections::SectionProvider;
pub use sections::grouping_sections;
pub use layout::GridSizing;
pub use layout::ScrollAnchor;

Modules§

layout
Layout strategies for GridView.
sections
Section grouping for GridView.

Structs§

GridView
A virtualized 2D tile grid backed by a ListModel<T>.
GridViewHandle
A handle on a GridView’s scroll position, for a caller outside the widget: a toolbar, a “show current” command, a search result, an observer of the application’s own state.
TileContext
Context passed to the tile delegate for each realized tile.

Enums§

GridTabTraversal
How Tab moves out of (or within) the grid.