cranpose_ui/bring_into_view.rs
1//! Bring-a-focused-field's-caret-into-view above the soft keyboard.
2//!
3//! A scroll container ([`crate::widgets::LazyColumn`], a `Modifier.vertical_scroll`
4//! column) installs a [`BringIntoViewResponder`] into composition via
5//! [`local_bring_into_view_responder`]. A focused [`crate::widgets::BasicTextField`]
6//! reads that responder and, on focus / caret move / keyboard animation, asks it
7//! to scroll the caret's window rect clear of the on-screen keyboard
8//! ([`crate::safe_area::local_ime_insets`]).
9//!
10//! The geometry decision is the pure, unit-tested [`scroll_delta_to_reveal`]; the
11//! responder only owns its viewport rect (reported into a cell by the layout pass
12//! through [`Modifier::report_window_rect`]) and how to apply a scroll delta to
13//! its own scroll state.
14
15use std::{
16 cell::{Cell, RefCell},
17 rc::Rc,
18};
19
20use cranpose_core::{CompositionLocal, compositionLocalOf};
21use cranpose_ui_graphics::Rect;
22
23use crate::modifier::Modifier;
24
25/// Breathing room (px) kept between the caret and the edge of the visible region
26/// when scrolling it into view, so the caret never sits flush against the
27/// keyboard or the viewport edge.
28pub const BRING_INTO_VIEW_MARGIN: f32 = 12.0;
29
30/// The scroll-offset delta needed to bring `caret` fully inside the un-obscured
31/// part of `viewport`.
32///
33/// All three arguments are in the same (window) coordinate space. `ime_bottom` is
34/// how many pixels at the **bottom of the viewport** are covered by the on-screen
35/// keyboard (0 when it is hidden); the usable region is therefore
36/// `[viewport.y, viewport.y + viewport.height - ime_bottom]`.
37///
38/// The return value is a delta to **add to the container's scroll offset**:
39/// * positive → scroll toward the content end (content moves up) so a caret
40/// hidden below the fold / behind the keyboard rises into view;
41/// * negative → scroll back toward the content start (content moves down) so a
42/// caret above the viewport top drops into view;
43/// * `0.0` → the caret already fits, or the keyboard leaves no usable space.
44///
45/// When the caret is taller than the usable region the top edge is prioritised so
46/// the start of the caret line stays visible.
47pub fn scroll_delta_to_reveal(caret: Rect, viewport: Rect, ime_bottom: f32) -> f32 {
48 let ime_bottom = ime_bottom.max(0.0);
49 let visible_top = viewport.y;
50 let visible_bottom = viewport.y + viewport.height - ime_bottom;
51 // Keyboard (or a degenerate viewport) leaves nothing usable: don't fight it.
52 if visible_bottom <= visible_top {
53 return 0.0;
54 }
55
56 let caret_top = caret.y;
57 let caret_bottom = caret.y + caret.height.max(0.0);
58
59 // Hidden below the fold (behind the keyboard or past the viewport bottom):
60 // scroll just enough to reveal the caret bottom plus a margin, but never so
61 // far that the caret top is pushed above the visible top.
62 if caret_bottom + BRING_INTO_VIEW_MARGIN > visible_bottom {
63 let needed = caret_bottom + BRING_INTO_VIEW_MARGIN - visible_bottom;
64 // Never scroll so far that the caret top is pushed above the visible
65 // top (matters only when the caret is taller than the usable region).
66 let max_delta = (caret_top - visible_top).max(0.0);
67 return needed.min(max_delta);
68 }
69
70 // Hidden above the top of the viewport: scroll back so the caret top clears
71 // the top edge by a margin.
72 if caret_top - BRING_INTO_VIEW_MARGIN < visible_top {
73 return caret_top - BRING_INTO_VIEW_MARGIN - visible_top;
74 }
75
76 0.0
77}
78
79/// A scroll container's ability to scroll a target window rect into view.
80///
81/// Installed into composition by scroll containers via
82/// [`local_bring_into_view_responder`] and read by a focused text field. Equality
83/// is by identity so providing the same remembered responder does not thrash the
84/// composition local.
85#[derive(Clone)]
86pub struct BringIntoViewResponder {
87 inner: Rc<dyn Fn(Rect, f32)>,
88}
89
90impl BringIntoViewResponder {
91 /// Builds a responder from a callback that receives the caret's window rect
92 /// and the keyboard inset (px covering the viewport bottom).
93 pub fn new(responder: impl Fn(Rect, f32) + 'static) -> Self {
94 Self {
95 inner: Rc::new(responder),
96 }
97 }
98
99 /// Asks the container to scroll `caret_window_rect` clear of a keyboard that
100 /// covers the bottom `ime_bottom` px of the viewport.
101 pub fn bring_into_view(&self, caret_window_rect: Rect, ime_bottom: f32) {
102 (self.inner)(caret_window_rect, ime_bottom);
103 }
104}
105
106impl PartialEq for BringIntoViewResponder {
107 fn eq(&self, other: &Self) -> bool {
108 Rc::ptr_eq(&self.inner, &other.inner)
109 }
110}
111
112impl std::fmt::Debug for BringIntoViewResponder {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("BringIntoViewResponder").finish()
115 }
116}
117
118/// CompositionLocal carrying the nearest scroll container's
119/// [`BringIntoViewResponder`], or `None` outside any scroll container.
120///
121/// Scroll containers provide it around their content; a focused text field reads
122/// `current()` to request that its caret be scrolled above the keyboard. Like the
123/// safe-area locals, the same instance is returned per thread.
124pub fn local_bring_into_view_responder() -> CompositionLocal<Option<BringIntoViewResponder>> {
125 thread_local! {
126 static LOCAL: RefCell<Option<CompositionLocal<Option<BringIntoViewResponder>>>> =
127 const { RefCell::new(None) };
128 }
129 LOCAL.with(|cell| {
130 cell.borrow_mut()
131 .get_or_insert_with(|| compositionLocalOf(|| None))
132 .clone()
133 })
134}
135
136impl Modifier {
137 /// Publishes this node's composited window rect (window coordinates,
138 /// resolved through ancestor scroll placement + graphics-layer translation)
139 /// into `sink` every layout pass.
140 ///
141 /// Scroll containers use it to expose their viewport bounds to a
142 /// [`BringIntoViewResponder`]. Positioning-only: it draws nothing and does
143 /// not affect layout.
144 pub fn report_window_rect(self, sink: Rc<Cell<Rect>>) -> Self {
145 self.then(Modifier::with_element(
146 crate::modifier_nodes::WindowRectReporterElement::new(sink),
147 ))
148 }
149
150 /// Publishes this node's composited window rect into observable state.
151 /// State changes schedule composition, so anchored overlays follow layout,
152 /// scrolling, graphics-layer translation, and viewport changes.
153 pub fn report_window_rect_state(self, sink: cranpose_core::MutableState<Rect>) -> Self {
154 self.then(Modifier::with_element(
155 crate::modifier_nodes::WindowRectReporterElement::from_state(sink),
156 ))
157 }
158
159 /// Publishes this node's measured size (logical px) into `sink` on every
160 /// measure pass — the `onSizeChanged` seam for consumers that need the
161 /// resolved size outside layout (shader morph geometry, lens overlays).
162 pub fn report_size(self, sink: Rc<Cell<cranpose_ui_graphics::Size>>) -> Self {
163 self.then(Modifier::with_element(
164 crate::modifier_nodes::SizeReporterElement::new(sink),
165 ))
166 }
167
168 /// Publishes this node's measured size into observable state, so an
169 /// actual size change schedules recomposition — size-reactive topology
170 /// without a `BoxWithConstraints` subcompose boundary. Writes are
171 /// equality-gated by `MutableState::set`, so a pass that re-measures the
172 /// node at its current size schedules nothing and a threshold-style use
173 /// (state feeds the CONTENT, the node's own size does not depend on it)
174 /// settles in one extra pass. Content whose measured size depends on the
175 /// reported size can still oscillate — the same self-referential hazard
176 /// as Compose's `onSizeChanged`, and no gate can decide it for you.
177 pub fn report_size_state(
178 self,
179 sink: cranpose_core::MutableState<cranpose_ui_graphics::Size>,
180 ) -> Self {
181 self.then(Modifier::with_element(
182 crate::modifier_nodes::SizeReporterElement::from_state(sink),
183 ))
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn rect(y: f32, height: f32) -> Rect {
192 Rect {
193 x: 0.0,
194 y,
195 width: 100.0,
196 height,
197 }
198 }
199
200 #[test]
201 fn visible_caret_needs_no_scroll() {
202 let viewport = rect(0.0, 1000.0);
203 let caret = rect(400.0, 20.0);
204 assert_eq!(scroll_delta_to_reveal(caret, viewport, 0.0), 0.0);
205 }
206
207 #[test]
208 fn caret_behind_keyboard_scrolls_content_up() {
209 // Viewport 0..1000, keyboard covers bottom 400 → usable 0..600.
210 let viewport = rect(0.0, 1000.0);
211 // Caret at y=700..720 is behind the keyboard.
212 let caret = rect(700.0, 20.0);
213 let delta = scroll_delta_to_reveal(caret, viewport, 400.0);
214 // Must move content up so the caret bottom (720) + margin clears 600.
215 assert!(
216 delta > 0.0,
217 "expected a positive (content-up) delta, got {delta}"
218 );
219 assert!(
220 (delta - (720.0 + BRING_INTO_VIEW_MARGIN - 600.0)).abs() < 0.01,
221 "delta {delta} should reveal the caret bottom plus a margin"
222 );
223 // After applying the delta the caret bottom sits at/above the fold.
224 let revealed_bottom = caret.y + caret.height - delta;
225 assert!(revealed_bottom <= 600.0 - BRING_INTO_VIEW_MARGIN + 0.01);
226 }
227
228 #[test]
229 fn caret_just_below_fold_uses_margin() {
230 let viewport = rect(0.0, 1000.0);
231 // Usable region 0..600; caret bottom exactly at 600 still needs a nudge
232 // for the margin.
233 let caret = rect(580.0, 20.0);
234 let delta = scroll_delta_to_reveal(caret, viewport, 400.0);
235 assert!((delta - BRING_INTO_VIEW_MARGIN).abs() < 0.01, "got {delta}");
236 }
237
238 #[test]
239 fn caret_above_viewport_top_scrolls_content_down() {
240 // Container starts at y=200 (below a header); caret sits above it.
241 let viewport = rect(200.0, 800.0);
242 let caret = rect(150.0, 20.0);
243 let delta = scroll_delta_to_reveal(caret, viewport, 0.0);
244 assert!(
245 delta < 0.0,
246 "expected a negative (content-down) delta, got {delta}"
247 );
248 assert!(
249 (delta - (150.0 - BRING_INTO_VIEW_MARGIN - 200.0)).abs() < 0.01,
250 "delta {delta} should reveal the caret top with a margin"
251 );
252 }
253
254 #[test]
255 fn no_usable_space_does_not_scroll() {
256 // Keyboard taller than the viewport: refuse rather than scroll wildly.
257 let viewport = rect(0.0, 300.0);
258 let caret = rect(280.0, 20.0);
259 assert_eq!(scroll_delta_to_reveal(caret, viewport, 400.0), 0.0);
260 }
261
262 #[test]
263 fn responder_identity_equality() {
264 let r = BringIntoViewResponder::new(|_, _| {});
265 assert_eq!(r, r.clone());
266 let other = BringIntoViewResponder::new(|_, _| {});
267 assert_ne!(r, other);
268 }
269
270 #[test]
271 fn responder_forwards_request() {
272 let seen: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
273 let seen2 = seen.clone();
274 let r = BringIntoViewResponder::new(move |caret: Rect, ime: f32| {
275 seen2.set(Some((caret.y, ime)));
276 });
277 r.bring_into_view(rect(42.0, 20.0), 300.0);
278 assert_eq!(seen.get(), Some((42.0, 300.0)));
279 }
280}