Skip to main content

teksilo_widgets/grid_view/
handle.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`GridViewHandle`]: scroll a [`GridView`](super::GridView) and move its
5//! keyboard cursor from outside, before it is laid out and across its
6//! rebuilds.
7//!
8//! The builder's own `scroll_to_index` / `ensure_index_visible` compute an
9//! offset at call time, from the geometry of the last layout. Before the
10//! first layout there is none, so they do nothing; and a change written in
11//! the same turn, a detail band opened above the tile, is not in it yet. The
12//! layout then keeps the content under the viewport's top edge still across
13//! that change, treating the offset just written as one of the old layout,
14//! and the tile lands a band's height away from where it was asked to be.
15//!
16//! A handle computes nothing when it is called. It records the request and
17//! asks for a relayout, and `GridView::place_children` applies the request
18//! once the band is measured and that anchoring has run: the offset is
19//! computed from the layout it will be shown in.
20//!
21//! The cursor is the grid's own signal, shared by the handle: a write is seen
22//! by the focus ring (bound `RepaintOnly` in `GridOverlay`) and by the grid's
23//! active descendant (bound `AccessibilityOnly` on the root), and a write
24//! made before mount is simply the value the first build finds.
25
26use std::cell::Cell;
27use std::rc::Rc;
28
29use teksilo_core::signal::Signal;
30
31use super::layout::ScrollAnchor;
32
33/// A reveal recorded by a [`GridViewHandle`], for the next layout to apply.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub(crate) enum RevealRequest {
36    /// Put the tile where `anchor` says, whatever is on screen now.
37    ScrollTo { index: usize, anchor: ScrollAnchor },
38    /// Scroll only when the tile is not fully visible, then per `anchor`.
39    Ensure { index: usize, anchor: ScrollAnchor },
40    /// The tile's row and its open detail band.
41    Band(usize),
42}
43
44impl RevealRequest {
45    /// The tile the request is about.
46    pub(crate) fn index(self) -> usize {
47        match self {
48            Self::ScrollTo { index, .. } | Self::Ensure { index, .. } | Self::Band(index) => index,
49        }
50    }
51}
52
53/// The state a grid and every handle on it share. Made with the grid, so a
54/// handle taken while the tree is being described reaches the same state the
55/// mounted grid reads, and keeps reaching it through the grid's rebuilds.
56#[derive(Debug)]
57pub(crate) struct RevealShared {
58    /// The latest request not yet applied. A later one replaces it.
59    pending: Cell<Option<RevealRequest>>,
60    /// Bound by the grid at `Relayout` in each build: bumped to have the
61    /// next frame lay the grid out and apply `pending`. Unbound before the
62    /// grid is mounted, when the first layout applies it anyway.
63    relayout: Signal<u64>,
64}
65
66impl RevealShared {
67    pub(crate) fn new() -> Rc<Self> {
68        Rc::new(Self {
69            pending: Cell::new(None),
70            relayout: Signal::new(0),
71        })
72    }
73
74    /// The trigger the grid binds at `Relayout`.
75    pub(crate) fn relayout_signal(&self) -> &Signal<u64> {
76        &self.relayout
77    }
78
79    /// Take the pending request, leaving none.
80    pub(crate) fn take(&self) -> Option<RevealRequest> {
81        self.pending.take()
82    }
83
84    fn request(&self, request: RevealRequest) {
85        self.pending.set(Some(request));
86        self.relayout.set(self.relayout.get().wrapping_add(1));
87    }
88}
89
90/// A handle on a [`GridView`](super::GridView)'s scroll position, for a
91/// caller outside the widget: a toolbar, a "show current" command, a search
92/// result, an observer of the application's own state.
93///
94/// Taken with [`GridView::handle`](super::GridView::handle), cheap to clone,
95/// and usable at once: a request made before the grid is mounted is kept and
96/// applied at its first layout, and the handle keeps working through every
97/// rebuild of the grid. Each method records a request and asks for a
98/// relayout; the layout applies it after it has measured the detail band and
99/// settled the scroll anchoring, so a request made in the same turn as a
100/// change of layout lands where the new layout puts the tile:
101///
102/// ```ignore
103/// // Open the band of tile 0 and put tile 30 at the top, in one turn.
104/// expanded.set(Some(0));
105/// handle.scroll_to_index(30, ScrollAnchor::Start);
106/// ```
107///
108/// Only the latest request is applied: a later one replaces one still
109/// pending. A request for a tile the grid does not have when it is applied
110/// does nothing.
111///
112/// The handle also reads and moves the grid's **keyboard cursor**, the tile
113/// the arrow keys step from, the focus ring marks and assistive technology is
114/// told is current: [`cursor`](Self::cursor),
115/// [`set_cursor`](Self::set_cursor) and [`cursor_signal`](Self::cursor_signal).
116/// In a multiple selection the cursor and the selection are separate: the
117/// accelerator with an arrow moves the cursor alone, and a selection written
118/// from outside leaves the cursor where it was.
119#[derive(Clone)]
120pub struct GridViewHandle {
121    shared: Rc<RevealShared>,
122    /// The grid's `focused_index`, the same signal the keyboard writes.
123    cursor: Signal<Option<usize>>,
124    /// The grid's item count, to clamp a cursor written from here.
125    len: Rc<dyn Fn() -> usize>,
126}
127
128impl std::fmt::Debug for GridViewHandle {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("GridViewHandle")
131            .field("pending", &self.shared.pending.get())
132            .field("cursor", &self.cursor.get())
133            .finish()
134    }
135}
136
137impl GridViewHandle {
138    pub(crate) fn new(
139        shared: Rc<RevealShared>,
140        cursor: Signal<Option<usize>>,
141        len: Rc<dyn Fn() -> usize>,
142    ) -> Self {
143        Self {
144            shared,
145            cursor,
146            len,
147        }
148    }
149
150    /// The tile the keyboard cursor is on, or `None` while the grid has no
151    /// cursor: it has not been navigated, clicked or given one by
152    /// [`set_cursor`](Self::set_cursor). With no cursor the keys start from
153    /// the first selected tile, and from the first or last tile when nothing
154    /// is selected.
155    ///
156    /// In a single selection the cursor follows the selection: the keys move
157    /// them together, and a selection written from outside moves the cursor
158    /// onto it. In a multiple selection they are separate: the accelerator
159    /// with an arrow (`Ctrl+Arrow`) moves the cursor alone, and a selection
160    /// written from outside leaves the cursor where it was.
161    pub fn cursor(&self) -> Option<usize> {
162        self.cursor.get()
163    }
164
165    /// Move the keyboard cursor to tile `index`, or remove it with `None`.
166    ///
167    /// An index past the last tile is clamped to the last tile; on an empty
168    /// grid the cursor is removed. The focus ring moves at the next paint,
169    /// while the grid holds keyboard focus, and the grid names the tile as
170    /// its active descendant at the next accessibility update when the tile
171    /// is realized. The next arrow key steps from it.
172    ///
173    /// Keyboard focus and the selection are left alone, and the grid does not
174    /// scroll: pair it with [`ensure_index_visible`](Self::ensure_index_visible)
175    /// to bring the tile into view. Usable before the grid is mounted (the
176    /// first build finds the cursor there) and across its rebuilds. In a
177    /// single selection the next change of selection puts the cursor back on
178    /// the selected tile.
179    pub fn set_cursor(&self, index: Option<usize>) {
180        let len = (self.len)();
181        let index = index.and_then(|i| len.checked_sub(1).map(|last| i.min(last)));
182        self.cursor.set(index);
183    }
184
185    /// A read-only signal of the cursor, as [`cursor`](Self::cursor) reports
186    /// it, for an application to observe or bind: a status line naming the
187    /// current tile, actions enabled for it. It changes with every write of
188    /// the cursor, by the keys, a click, the data shifting it or
189    /// [`set_cursor`](Self::set_cursor).
190    pub fn cursor_signal(&self) -> Signal<Option<usize>> {
191        self.cursor.map(|index| *index)
192    }
193
194    /// Scroll to tile `index`, forcing its position in the viewport per
195    /// `anchor`: [`ScrollAnchor::Start`] puts its top at the viewport's top,
196    /// [`Center`](ScrollAnchor::Center) centres it,
197    /// [`End`](ScrollAnchor::End) puts its bottom at the viewport's bottom,
198    /// and [`Auto`](ScrollAnchor::Auto) scrolls the least that shows it.
199    /// Clamped to the scroll range. Applied at the next layout.
200    pub fn scroll_to_index(&self, index: usize, anchor: ScrollAnchor) {
201        self.shared
202            .request(RevealRequest::ScrollTo { index, anchor });
203    }
204
205    /// Bring tile `index` into view: nothing when it is already fully
206    /// visible, otherwise a scroll per `anchor`, as
207    /// [`scroll_to_index`](Self::scroll_to_index). Applied at the next
208    /// layout, against the layout it will be shown in.
209    pub fn ensure_index_visible(&self, index: usize, anchor: ScrollAnchor) {
210        self.shared.request(RevealRequest::Ensure { index, anchor });
211    }
212
213    /// Scroll so tile `index` and its open detail band are both visible:
214    /// the least scroll that shows the span from the top of the tile's row
215    /// to the bottom of its band, and the row first when the two do not fit
216    /// together, so the band reads as the tile's. This is what the grid does
217    /// itself when a user opens a band.
218    ///
219    /// When `index`'s band is not open at the layout that applies it (no
220    /// [`detail_row`](super::GridView::detail_row), another tile disclosed,
221    /// or a tile with nothing to disclose), the tile alone is brought into
222    /// view, as [`ensure_index_visible`](Self::ensure_index_visible) with
223    /// [`ScrollAnchor::Auto`]. Open the band and call this in the same turn:
224    /// the band is measured before the request is applied.
225    pub fn reveal_band(&self, index: usize) {
226        self.shared.request(RevealRequest::Band(index));
227    }
228}