cranpose-ui 0.1.164

UI primitives for Cranpose
Documentation
//! Bring-a-focused-field's-caret-into-view above the soft keyboard.
//!
//! A scroll container ([`crate::widgets::LazyColumn`], a `Modifier.vertical_scroll`
//! column) installs a [`BringIntoViewResponder`] into composition via
//! [`local_bring_into_view_responder`]. A focused [`crate::widgets::BasicTextField`]
//! reads that responder and, on focus / caret move / keyboard animation, asks it
//! to scroll the caret's window rect clear of the on-screen keyboard
//! ([`crate::safe_area::local_ime_insets`]).
//!
//! The geometry decision is the pure, unit-tested [`scroll_delta_to_reveal`]; the
//! responder only owns its viewport rect (reported into a cell by the layout pass
//! through [`Modifier::report_window_rect`]) and how to apply a scroll delta to
//! its own scroll state.

use std::{
    cell::{Cell, RefCell},
    rc::Rc,
};

use cranpose_core::{CompositionLocal, compositionLocalOf};
use cranpose_ui_graphics::Rect;

use crate::modifier::Modifier;

/// Breathing room (px) kept between the caret and the edge of the visible region
/// when scrolling it into view, so the caret never sits flush against the
/// keyboard or the viewport edge.
pub const BRING_INTO_VIEW_MARGIN: f32 = 12.0;

/// The scroll-offset delta needed to bring `caret` fully inside the un-obscured
/// part of `viewport`.
///
/// All three arguments are in the same (window) coordinate space. `ime_bottom` is
/// how many pixels at the **bottom of the viewport** are covered by the on-screen
/// keyboard (0 when it is hidden); the usable region is therefore
/// `[viewport.y, viewport.y + viewport.height - ime_bottom]`.
///
/// The return value is a delta to **add to the container's scroll offset**:
/// * positive → scroll toward the content end (content moves up) so a caret
///   hidden below the fold / behind the keyboard rises into view;
/// * negative → scroll back toward the content start (content moves down) so a
///   caret above the viewport top drops into view;
/// * `0.0` → the caret already fits, or the keyboard leaves no usable space.
///
/// When the caret is taller than the usable region the top edge is prioritised so
/// the start of the caret line stays visible.
pub fn scroll_delta_to_reveal(caret: Rect, viewport: Rect, ime_bottom: f32) -> f32 {
    let ime_bottom = ime_bottom.max(0.0);
    let visible_top = viewport.y;
    let visible_bottom = viewport.y + viewport.height - ime_bottom;
    if visible_bottom <= visible_top {
        return 0.0;
    }

    let caret_top = caret.y;
    let caret_bottom = caret.y + caret.height.max(0.0);

    if caret_bottom + BRING_INTO_VIEW_MARGIN > visible_bottom {
        let needed = caret_bottom + BRING_INTO_VIEW_MARGIN - visible_bottom;
        let max_delta = (caret_top - visible_top).max(0.0);
        return needed.min(max_delta);
    }

    if caret_top - BRING_INTO_VIEW_MARGIN < visible_top {
        return caret_top - BRING_INTO_VIEW_MARGIN - visible_top;
    }

    0.0
}

/// A scroll container's ability to scroll a target window rect into view.
///
/// Installed into composition by scroll containers via
/// [`local_bring_into_view_responder`] and read by a focused text field. Equality
/// is by identity so providing the same remembered responder does not thrash the
/// composition local.
#[derive(Clone)]
pub struct BringIntoViewResponder {
    inner: Rc<dyn Fn(Rect, f32)>,
}

impl BringIntoViewResponder {
    /// Builds a responder from a callback that receives the caret's window rect
    /// and the keyboard inset (px covering the viewport bottom).
    pub fn new(responder: impl Fn(Rect, f32) + 'static) -> Self {
        Self {
            inner: Rc::new(responder),
        }
    }

    /// Asks the container to scroll `caret_window_rect` clear of a keyboard that
    /// covers the bottom `ime_bottom` px of the viewport.
    pub fn bring_into_view(&self, caret_window_rect: Rect, ime_bottom: f32) {
        (self.inner)(caret_window_rect, ime_bottom);
    }
}

impl PartialEq for BringIntoViewResponder {
    fn eq(&self, other: &Self) -> bool {
        Rc::ptr_eq(&self.inner, &other.inner)
    }
}

impl std::fmt::Debug for BringIntoViewResponder {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("BringIntoViewResponder").finish()
    }
}

/// CompositionLocal carrying the nearest scroll container's
/// [`BringIntoViewResponder`], or `None` outside any scroll container.
///
/// Scroll containers provide it around their content; a focused text field reads
/// `current()` to request that its caret be scrolled above the keyboard. Like the
/// safe-area locals, the same instance is returned per thread.
pub fn local_bring_into_view_responder() -> CompositionLocal<Option<BringIntoViewResponder>> {
    thread_local! {
        static LOCAL: RefCell<Option<CompositionLocal<Option<BringIntoViewResponder>>>> =
            const { RefCell::new(None) };
    }
    LOCAL.with(|cell| {
        cell.borrow_mut()
            .get_or_insert_with(|| compositionLocalOf(|| None))
            .clone()
    })
}

impl Modifier {
    /// Publishes this node's composited window rect (window coordinates,
    /// resolved through ancestor scroll placement + graphics-layer translation)
    /// into `sink` every layout pass.
    ///
    /// Scroll containers use it to expose their viewport bounds to a
    /// [`BringIntoViewResponder`]. Positioning-only: it draws nothing and does
    /// not affect layout.
    pub fn report_window_rect(self, sink: Rc<Cell<Rect>>) -> Self {
        self.then(Modifier::with_element(
            crate::modifier_nodes::WindowRectReporterElement::new(sink),
        ))
    }

    /// Publishes this node's composited window rect into observable state.
    /// State changes schedule composition, so anchored overlays follow layout,
    /// scrolling, graphics-layer translation, and viewport changes.
    pub fn report_window_rect_state(self, sink: cranpose_core::MutableState<Rect>) -> Self {
        self.then(Modifier::with_element(
            crate::modifier_nodes::WindowRectReporterElement::from_state(sink),
        ))
    }

    /// Publishes this node's measured size (logical px) into `sink` on every
    /// measure pass — the `onSizeChanged` seam for consumers that need the
    /// resolved size outside layout (shader morph geometry, lens overlays).
    pub fn report_size(self, sink: Rc<Cell<cranpose_ui_graphics::Size>>) -> Self {
        self.then(Modifier::with_element(
            crate::modifier_nodes::SizeReporterElement::new(sink),
        ))
    }

    /// Publishes this node's measured size into observable state, so an
    /// actual size change schedules recomposition — size-reactive topology
    /// without a `BoxWithConstraints` subcompose boundary. Writes are
    /// equality-gated by `MutableState::set`, so a pass that re-measures the
    /// node at its current size schedules nothing and a threshold-style use
    /// (state feeds the CONTENT, the node's own size does not depend on it)
    /// settles in one extra pass. Content whose measured size depends on the
    /// reported size can still oscillate — the same self-referential hazard
    /// as Compose's `onSizeChanged`, and no gate can decide it for you.
    pub fn report_size_state(
        self,
        sink: cranpose_core::MutableState<cranpose_ui_graphics::Size>,
    ) -> Self {
        self.then(Modifier::with_element(
            crate::modifier_nodes::SizeReporterElement::from_state(sink),
        ))
    }
}

#[cfg(test)]
#[path = "tests/bring_into_view_tests.rs"]
mod tests;