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}