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