cranpose_ui/
bring_into_view.rs1use 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
25pub const BRING_INTO_VIEW_MARGIN: f32 = 12.0;
29
30pub 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#[derive(Clone)]
78pub struct BringIntoViewResponder {
79 inner: Rc<dyn Fn(Rect, f32)>,
80}
81
82impl BringIntoViewResponder {
83 pub fn new(responder: impl Fn(Rect, f32) + 'static) -> Self {
86 Self {
87 inner: Rc::new(responder),
88 }
89 }
90
91 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
110pub 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 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 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 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 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}