Skip to main content

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    if visible_bottom <= visible_top {
52        return 0.0;
53    }
54
55    let caret_top = caret.y;
56    let caret_bottom = caret.y + caret.height.max(0.0);
57
58    if caret_bottom + BRING_INTO_VIEW_MARGIN > visible_bottom {
59        let needed = caret_bottom + BRING_INTO_VIEW_MARGIN - visible_bottom;
60        let max_delta = (caret_top - visible_top).max(0.0);
61        return needed.min(max_delta);
62    }
63
64    if caret_top - BRING_INTO_VIEW_MARGIN < visible_top {
65        return caret_top - BRING_INTO_VIEW_MARGIN - visible_top;
66    }
67
68    0.0
69}
70
71/// A scroll container's ability to scroll a target window rect into view.
72///
73/// Installed into composition by scroll containers via
74/// [`local_bring_into_view_responder`] and read by a focused text field. Equality
75/// is by identity so providing the same remembered responder does not thrash the
76/// composition local.
77#[derive(Clone)]
78pub struct BringIntoViewResponder {
79    inner: Rc<dyn Fn(Rect, f32)>,
80}
81
82impl BringIntoViewResponder {
83    /// Builds a responder from a callback that receives the caret's window rect
84    /// and the keyboard inset (px covering the viewport bottom).
85    pub fn new(responder: impl Fn(Rect, f32) + 'static) -> Self {
86        Self {
87            inner: Rc::new(responder),
88        }
89    }
90
91    /// Asks the container to scroll `caret_window_rect` clear of a keyboard that
92    /// covers the bottom `ime_bottom` px of the viewport.
93    pub fn bring_into_view(&self, caret_window_rect: Rect, ime_bottom: f32) {
94        (self.inner)(caret_window_rect, ime_bottom);
95    }
96}
97
98impl PartialEq for BringIntoViewResponder {
99    fn eq(&self, other: &Self) -> bool {
100        Rc::ptr_eq(&self.inner, &other.inner)
101    }
102}
103
104impl std::fmt::Debug for BringIntoViewResponder {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("BringIntoViewResponder").finish()
107    }
108}
109
110/// CompositionLocal carrying the nearest scroll container's
111/// [`BringIntoViewResponder`], or `None` outside any scroll container.
112///
113/// Scroll containers provide it around their content; a focused text field reads
114/// `current()` to request that its caret be scrolled above the keyboard. Like the
115/// safe-area locals, the same instance is returned per thread.
116pub fn local_bring_into_view_responder() -> CompositionLocal<Option<BringIntoViewResponder>> {
117    thread_local! {
118        static LOCAL: RefCell<Option<CompositionLocal<Option<BringIntoViewResponder>>>> =
119            const { RefCell::new(None) };
120    }
121    LOCAL.with(|cell| {
122        cell.borrow_mut()
123            .get_or_insert_with(|| compositionLocalOf(|| None))
124            .clone()
125    })
126}
127
128impl Modifier {
129    /// Publishes this node's composited window rect (window coordinates,
130    /// resolved through ancestor scroll placement + graphics-layer translation)
131    /// into `sink` every layout pass.
132    ///
133    /// Scroll containers use it to expose their viewport bounds to a
134    /// [`BringIntoViewResponder`]. Positioning-only: it draws nothing and does
135    /// not affect layout.
136    pub fn report_window_rect(self, sink: Rc<Cell<Rect>>) -> Self {
137        self.then(Modifier::with_element(
138            crate::modifier_nodes::WindowRectReporterElement::new(sink),
139        ))
140    }
141
142    /// Publishes this node's composited window rect into observable state.
143    /// State changes schedule composition, so anchored overlays follow layout,
144    /// scrolling, graphics-layer translation, and viewport changes.
145    pub fn report_window_rect_state(self, sink: cranpose_core::MutableState<Rect>) -> Self {
146        self.then(Modifier::with_element(
147            crate::modifier_nodes::WindowRectReporterElement::from_state(sink),
148        ))
149    }
150
151    /// Publishes this node's measured size (logical px) into `sink` on every
152    /// measure pass — the `onSizeChanged` seam for consumers that need the
153    /// resolved size outside layout (shader morph geometry, lens overlays).
154    pub fn report_size(self, sink: Rc<Cell<cranpose_ui_graphics::Size>>) -> Self {
155        self.then(Modifier::with_element(
156            crate::modifier_nodes::SizeReporterElement::new(sink),
157        ))
158    }
159
160    /// Publishes this node's measured size into observable state, so an
161    /// actual size change schedules recomposition — size-reactive topology
162    /// without a `BoxWithConstraints` subcompose boundary. Writes are
163    /// equality-gated by `MutableState::set`, so a pass that re-measures the
164    /// node at its current size schedules nothing and a threshold-style use
165    /// (state feeds the CONTENT, the node's own size does not depend on it)
166    /// settles in one extra pass. Content whose measured size depends on the
167    /// reported size can still oscillate — the same self-referential hazard
168    /// as Compose's `onSizeChanged`, and no gate can decide it for you.
169    pub fn report_size_state(
170        self,
171        sink: cranpose_core::MutableState<cranpose_ui_graphics::Size>,
172    ) -> Self {
173        self.then(Modifier::with_element(
174            crate::modifier_nodes::SizeReporterElement::from_state(sink),
175        ))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn rect(y: f32, height: f32) -> Rect {
184        Rect {
185            x: 0.0,
186            y,
187            width: 100.0,
188            height,
189        }
190    }
191
192    #[test]
193    fn visible_caret_needs_no_scroll() {
194        let viewport = rect(0.0, 1000.0);
195        let caret = rect(400.0, 20.0);
196        assert_eq!(scroll_delta_to_reveal(caret, viewport, 0.0), 0.0);
197    }
198
199    #[test]
200    fn caret_behind_keyboard_scrolls_content_up() {
201        let viewport = rect(0.0, 1000.0);
202        let caret = rect(700.0, 20.0);
203        let delta = scroll_delta_to_reveal(caret, viewport, 400.0);
204        assert!(
205            delta > 0.0,
206            "expected a positive (content-up) delta, got {delta}"
207        );
208        assert!(
209            (delta - (720.0 + BRING_INTO_VIEW_MARGIN - 600.0)).abs() < 0.01,
210            "delta {delta} should reveal the caret bottom plus a margin"
211        );
212        let revealed_bottom = caret.y + caret.height - delta;
213        assert!(revealed_bottom <= 600.0 - BRING_INTO_VIEW_MARGIN + 0.01);
214    }
215
216    #[test]
217    fn caret_just_below_fold_uses_margin() {
218        let viewport = rect(0.0, 1000.0);
219        let caret = rect(580.0, 20.0);
220        let delta = scroll_delta_to_reveal(caret, viewport, 400.0);
221        assert!((delta - BRING_INTO_VIEW_MARGIN).abs() < 0.01, "got {delta}");
222    }
223
224    #[test]
225    fn caret_above_viewport_top_scrolls_content_down() {
226        let viewport = rect(200.0, 800.0);
227        let caret = rect(150.0, 20.0);
228        let delta = scroll_delta_to_reveal(caret, viewport, 0.0);
229        assert!(
230            delta < 0.0,
231            "expected a negative (content-down) delta, got {delta}"
232        );
233        assert!(
234            (delta - (150.0 - BRING_INTO_VIEW_MARGIN - 200.0)).abs() < 0.01,
235            "delta {delta} should reveal the caret top with a margin"
236        );
237    }
238
239    #[test]
240    fn no_usable_space_does_not_scroll() {
241        let viewport = rect(0.0, 300.0);
242        let caret = rect(280.0, 20.0);
243        assert_eq!(scroll_delta_to_reveal(caret, viewport, 400.0), 0.0);
244    }
245
246    #[test]
247    fn responder_identity_equality() {
248        let r = BringIntoViewResponder::new(|_, _| {});
249        assert_eq!(r, r.clone());
250        let other = BringIntoViewResponder::new(|_, _| {});
251        assert_ne!(r, other);
252    }
253
254    #[test]
255    fn responder_forwards_request() {
256        let seen: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
257        let seen2 = seen.clone();
258        let r = BringIntoViewResponder::new(move |caret: Rect, ime: f32| {
259            seen2.set(Some((caret.y, ime)));
260        });
261        r.bring_into_view(rect(42.0, 20.0), 300.0);
262        assert_eq!(seen.get(), Some((42.0, 300.0)));
263    }
264}