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::{Point, Rect, WindowCoordinates};
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 the required vertical movement in window pixels:
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
71pub(crate) fn local_scroll_delta_to_reveal(
72    caret: Rect,
73    viewport: WindowCoordinates,
74    ime_bottom: f32,
75) -> f32 {
76    let delta = scroll_delta_to_reveal(caret, viewport.bounds(), ime_bottom);
77    if delta.abs() <= f32::EPSILON {
78        return 0.0;
79    }
80    let transform = viewport.local_to_window;
81    let Some(inverse) = transform.inverse() else {
82        return 0.0;
83    };
84    let anchor = Point {
85        x: caret.x + caret.width * 0.5,
86        y: if delta > 0.0 {
87            caret.y + caret.height
88        } else {
89            caret.y
90        },
91    };
92    let local = inverse.map_point(anchor);
93    let matrix = transform.matrix();
94    let target_y = anchor.y - delta;
95    let denominator = matrix[1][1] - target_y * matrix[2][1];
96    if denominator.abs() <= f32::EPSILON {
97        return 0.0;
98    }
99    let homogeneous_w = matrix[2][0] * local.x + matrix[2][1] * local.y + matrix[2][2];
100    let local_delta = delta * homogeneous_w / denominator;
101    if local_delta.is_finite() {
102        local_delta
103    } else {
104        0.0
105    }
106}
107
108/// A scroll container's ability to scroll a target window rect into view.
109///
110/// Installed into composition by scroll containers via
111/// [`local_bring_into_view_responder`] and read by a focused text field. Equality
112/// is by identity so providing the same remembered responder does not thrash the
113/// composition local.
114#[derive(Clone)]
115pub struct BringIntoViewResponder {
116    inner: Rc<dyn Fn(Rect, f32)>,
117}
118
119impl BringIntoViewResponder {
120    /// Builds a responder from a callback that receives the caret's window rect
121    /// and the keyboard inset (px covering the viewport bottom).
122    pub fn new(responder: impl Fn(Rect, f32) + 'static) -> Self {
123        Self {
124            inner: Rc::new(responder),
125        }
126    }
127
128    /// Asks the container to scroll `caret_window_rect` clear of a keyboard that
129    /// covers the bottom `ime_bottom` px of the viewport.
130    pub fn bring_into_view(&self, caret_window_rect: Rect, ime_bottom: f32) {
131        (self.inner)(caret_window_rect, ime_bottom);
132    }
133}
134
135impl PartialEq for BringIntoViewResponder {
136    fn eq(&self, other: &Self) -> bool {
137        Rc::ptr_eq(&self.inner, &other.inner)
138    }
139}
140
141impl std::fmt::Debug for BringIntoViewResponder {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("BringIntoViewResponder").finish()
144    }
145}
146
147/// CompositionLocal carrying the nearest scroll container's
148/// [`BringIntoViewResponder`], or `None` outside any scroll container.
149///
150/// Scroll containers provide it around their content; a focused text field reads
151/// `current()` to request that its caret be scrolled above the keyboard. Like the
152/// safe-area locals, the same instance is returned per thread.
153pub fn local_bring_into_view_responder() -> CompositionLocal<Option<BringIntoViewResponder>> {
154    thread_local! {
155        static LOCAL: RefCell<Option<CompositionLocal<Option<BringIntoViewResponder>>>> =
156            const { RefCell::new(None) };
157    }
158    LOCAL.with(|cell| {
159        cell.borrow_mut()
160            .get_or_insert_with(|| compositionLocalOf(|| None))
161            .clone()
162    })
163}
164
165impl Modifier {
166    /// Publishes this node's local size and complete local-to-window mapping.
167    /// Coordinates include placement, scrolling, and graphics-layer transforms.
168    /// They use logical pixels in the window containing this node.
169    pub fn report_window_coordinates(
170        self,
171        sink: Rc<Cell<cranpose_ui_graphics::WindowCoordinates>>,
172    ) -> Self {
173        self.then(Modifier::with_element(
174            crate::modifier_nodes::WindowRectReporterElement::from_coordinates(sink),
175        ))
176    }
177
178    /// Publishes this node's composited window rect (window coordinates,
179    /// resolved through ancestor scroll placement and graphics-layer transforms)
180    /// into `sink` every layout pass.
181    ///
182    /// Scroll containers use it to expose their viewport bounds to a
183    /// [`BringIntoViewResponder`]. Positioning-only: it draws nothing and does
184    /// not affect layout.
185    pub fn report_window_rect(self, sink: Rc<Cell<Rect>>) -> Self {
186        self.then(Modifier::with_element(
187            crate::modifier_nodes::WindowRectReporterElement::new(sink),
188        ))
189    }
190
191    /// Publishes this node's composited window rect into observable state.
192    /// State changes schedule composition, so anchored overlays follow layout,
193    /// scrolling, graphics-layer transforms, and viewport changes.
194    pub fn report_window_rect_state(self, sink: cranpose_core::MutableState<Rect>) -> Self {
195        self.then(Modifier::with_element(
196            crate::modifier_nodes::WindowRectReporterElement::from_state(sink),
197        ))
198    }
199
200    /// Publishes this node's measured size (logical px) into `sink` on every
201    /// measure pass — the `onSizeChanged` seam for consumers that need the
202    /// resolved size outside layout (shader morph geometry, lens overlays).
203    pub fn report_size(self, sink: Rc<Cell<cranpose_ui_graphics::Size>>) -> Self {
204        self.then(Modifier::with_element(
205            crate::modifier_nodes::SizeReporterElement::new(sink),
206        ))
207    }
208
209    /// Publishes this node's measured size into observable state, so an
210    /// actual size change schedules recomposition — size-reactive topology
211    /// without a `BoxWithConstraints` subcompose boundary. Writes are
212    /// equality-gated by `MutableState::set`, so a pass that re-measures the
213    /// node at its current size schedules nothing and a threshold-style use
214    /// (state feeds the CONTENT, the node's own size does not depend on it)
215    /// settles in one extra pass. Content whose measured size depends on the
216    /// reported size can still oscillate — the same self-referential hazard
217    /// as Compose's `onSizeChanged`, and no gate can decide it for you.
218    pub fn report_size_state(
219        self,
220        sink: cranpose_core::MutableState<cranpose_ui_graphics::Size>,
221    ) -> Self {
222        self.then(Modifier::with_element(
223            crate::modifier_nodes::SizeReporterElement::from_state(sink),
224        ))
225    }
226}
227
228#[cfg(test)]
229#[path = "tests/bring_into_view_tests.rs"]
230mod tests;