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, rc::Rc};
16
17use cranpose_core::{CompositionLocal, compositionLocalOf};
18use cranpose_ui_graphics::{Point, Rect, WindowCoordinates};
19
20use crate::modifier::Modifier;
21
22/// Breathing room (px) kept between the caret and the edge of the visible region
23/// when scrolling it into view, so the caret never sits flush against the
24/// keyboard or the viewport edge.
25pub const BRING_INTO_VIEW_MARGIN: f32 = 12.0;
26
27/// The scroll-offset delta needed to bring `caret` fully inside the un-obscured
28/// part of `viewport`.
29///
30/// All three arguments are in the same (window) coordinate space. `ime_bottom` is
31/// how many pixels at the **bottom of the viewport** are covered by the on-screen
32/// keyboard (0 when it is hidden); the usable region is therefore
33/// `[viewport.y, viewport.y + viewport.height - ime_bottom]`.
34///
35/// The return value is the required vertical movement in window pixels:
36/// * positive → scroll toward the content end (content moves up) so a caret
37///   hidden below the fold / behind the keyboard rises into view;
38/// * negative → scroll back toward the content start (content moves down) so a
39///   caret above the viewport top drops into view;
40/// * `0.0` → the caret already fits, or the keyboard leaves no usable space.
41///
42/// When the caret is taller than the usable region the top edge is prioritised so
43/// the start of the caret line stays visible.
44pub fn scroll_delta_to_reveal(caret: Rect, viewport: Rect, ime_bottom: f32) -> f32 {
45    let ime_bottom = ime_bottom.max(0.0);
46    let visible_top = viewport.y;
47    let visible_bottom = viewport.y + viewport.height - ime_bottom;
48    if visible_bottom <= visible_top {
49        return 0.0;
50    }
51
52    let caret_top = caret.y;
53    let caret_bottom = caret.y + caret.height.max(0.0);
54
55    if caret_bottom + BRING_INTO_VIEW_MARGIN > visible_bottom {
56        let needed = caret_bottom + BRING_INTO_VIEW_MARGIN - visible_bottom;
57        let max_delta = (caret_top - visible_top).max(0.0);
58        return needed.min(max_delta);
59    }
60
61    if caret_top - BRING_INTO_VIEW_MARGIN < visible_top {
62        return caret_top - BRING_INTO_VIEW_MARGIN - visible_top;
63    }
64
65    0.0
66}
67
68pub(crate) fn local_scroll_delta_to_reveal(
69    caret: Rect,
70    viewport: WindowCoordinates,
71    ime_bottom: f32,
72) -> f32 {
73    let delta = scroll_delta_to_reveal(caret, viewport.bounds(), ime_bottom);
74    if delta.abs() <= f32::EPSILON {
75        return 0.0;
76    }
77    let transform = viewport.local_to_window;
78    let Some(inverse) = transform.inverse() else {
79        return 0.0;
80    };
81    let anchor = Point {
82        x: caret.x + caret.width * 0.5,
83        y: if delta > 0.0 {
84            caret.y + caret.height
85        } else {
86            caret.y
87        },
88    };
89    let local = inverse.map_point(anchor);
90    let matrix = transform.matrix();
91    let target_y = anchor.y - delta;
92    let denominator = matrix[1][1] - target_y * matrix[2][1];
93    if denominator.abs() <= f32::EPSILON {
94        return 0.0;
95    }
96    let homogeneous_w = matrix[2][0] * local.x + matrix[2][1] * local.y + matrix[2][2];
97    let local_delta = delta * homogeneous_w / denominator;
98    if local_delta.is_finite() {
99        local_delta
100    } else {
101        0.0
102    }
103}
104
105/// A scroll container's ability to scroll a target window rect into view.
106///
107/// Installed into composition by scroll containers via
108/// [`local_bring_into_view_responder`] and read by a focused text field. Equality
109/// is by identity so providing the same remembered responder does not thrash the
110/// composition local.
111#[derive(Clone)]
112pub struct BringIntoViewResponder {
113    inner: Rc<dyn Fn(Rect, f32)>,
114}
115
116impl BringIntoViewResponder {
117    /// Builds a responder from a callback that receives the caret's window rect
118    /// and the keyboard inset (px covering the viewport bottom).
119    pub fn new(responder: impl Fn(Rect, f32) + 'static) -> Self {
120        Self {
121            inner: Rc::new(responder),
122        }
123    }
124
125    /// Asks the container to scroll `caret_window_rect` clear of a keyboard that
126    /// covers the bottom `ime_bottom` px of the viewport.
127    pub fn bring_into_view(&self, caret_window_rect: Rect, ime_bottom: f32) {
128        (self.inner)(caret_window_rect, ime_bottom);
129    }
130}
131
132impl PartialEq for BringIntoViewResponder {
133    fn eq(&self, other: &Self) -> bool {
134        Rc::ptr_eq(&self.inner, &other.inner)
135    }
136}
137
138impl std::fmt::Debug for BringIntoViewResponder {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("BringIntoViewResponder").finish()
141    }
142}
143
144/// CompositionLocal carrying the nearest scroll container's
145/// [`BringIntoViewResponder`], or `None` outside any scroll container.
146///
147/// Scroll containers provide it around their content; a focused text field reads
148/// `current()` to request that its caret be scrolled above the keyboard. Like the
149/// safe-area locals, the same instance is returned per thread.
150pub fn local_bring_into_view_responder() -> CompositionLocal<Option<BringIntoViewResponder>> {
151    crate::environment_locals::cached_local(
152        |locals| &locals.bring_into_view,
153        || compositionLocalOf(|| None),
154    )
155}
156
157impl Modifier {
158    /// Publishes this node's local size and complete local-to-window mapping.
159    /// Coordinates include placement, scrolling, and graphics-layer transforms.
160    /// They use logical pixels in the window containing this node.
161    pub fn report_window_coordinates(
162        self,
163        sink: Rc<Cell<cranpose_ui_graphics::WindowCoordinates>>,
164    ) -> Self {
165        self.then(Modifier::with_element(
166            crate::modifier_nodes::WindowRectReporterElement::from_coordinates(sink),
167        ))
168    }
169
170    /// Publishes this node's composited window rect (window coordinates,
171    /// resolved through ancestor scroll placement and graphics-layer transforms)
172    /// into `sink` every layout pass.
173    ///
174    /// Scroll containers use it to expose their viewport bounds to a
175    /// [`BringIntoViewResponder`]. Positioning-only: it draws nothing and does
176    /// not affect layout.
177    pub fn report_window_rect(self, sink: Rc<Cell<Rect>>) -> Self {
178        self.then(Modifier::with_element(
179            crate::modifier_nodes::WindowRectReporterElement::new(sink),
180        ))
181    }
182
183    /// Publishes this node's composited window rect into observable state.
184    /// State changes schedule composition, so anchored overlays follow layout,
185    /// scrolling, graphics-layer transforms, and viewport changes.
186    pub fn report_window_rect_state(self, sink: cranpose_core::MutableState<Rect>) -> Self {
187        self.then(Modifier::with_element(
188            crate::modifier_nodes::WindowRectReporterElement::from_state(sink),
189        ))
190    }
191
192    /// Publishes this node's measured size (logical px) into `sink` on every
193    /// measure pass — the `onSizeChanged` seam for consumers that need the
194    /// resolved size outside layout (shader morph geometry, lens overlays).
195    pub fn report_size(self, sink: Rc<Cell<cranpose_ui_graphics::Size>>) -> Self {
196        self.then(Modifier::with_element(
197            crate::modifier_nodes::SizeReporterElement::new(sink),
198        ))
199    }
200
201    /// Publishes this node's measured size into observable state, so an
202    /// actual size change schedules recomposition — size-reactive topology
203    /// without a `BoxWithConstraints` subcompose boundary. Writes are
204    /// equality-gated by `MutableState::set`, so a pass that re-measures the
205    /// node at its current size schedules nothing and a threshold-style use
206    /// (state feeds the CONTENT, the node's own size does not depend on it)
207    /// settles in one extra pass. Content whose measured size depends on the
208    /// reported size can still oscillate — the same self-referential hazard
209    /// as Compose's `onSizeChanged`, and no gate can decide it for you.
210    pub fn report_size_state(
211        self,
212        sink: cranpose_core::MutableState<cranpose_ui_graphics::Size>,
213    ) -> Self {
214        self.then(Modifier::with_element(
215            crate::modifier_nodes::SizeReporterElement::from_state(sink),
216        ))
217    }
218}
219
220#[cfg(test)]
221#[path = "tests/bring_into_view_tests.rs"]
222mod tests;