1#![allow(non_snake_case)]
7
8use crate::bring_into_view::local_bring_into_view_responder;
9use crate::clipboard_session::{clipboard_read_text, clipboard_write_text};
10use crate::composable;
11use crate::layout::policies::EmptyMeasurePolicy;
12use crate::modifier::Modifier;
13use crate::safe_area::local_ime_insets;
14use crate::text::{measure_text, AnnotatedString, TextStyle};
15use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_paste, dispatch_select_all};
16use crate::text_field_modifier_node::{
17 TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
18};
19use crate::text_selection::{
20 selection_after_handle_drag, HandleGrabOffset, HandleKind, LineAffinity, HANDLE_RADIUS,
21};
22use crate::widgets::{
23 loupe_target_for_drag, CaretActionMenu, Layout, SelectionHandle, SelectionLoupe,
24 TextSelectionMenu,
25};
26use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
27use cranpose_foundation::modifier_element;
28use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
29use cranpose_ui_graphics::{Color, Point, Rect};
30use std::cell::{Cell, RefCell};
31use std::rc::{Rc, Weak};
32
33pub const SELECTION_HIGHLIGHT_ALPHA: f32 = 0.32;
38
39const TEXT_LONG_PRESS_MS: u64 = 500;
42const TEXT_LONG_PRESS_SLOP: f32 = 12.0;
45
46struct LongPressWatcher {
53 controller: TextFieldHandleController,
54 state: TextFieldState,
55 style: TextStyle,
56 start: Point,
57 start_nanos: Cell<Option<u64>>,
58 registration: RefCell<Option<cranpose_core::internal::FrameCallbackRegistration>>,
59 frame_clock: cranpose_core::internal::FrameClock,
60}
61
62impl LongPressWatcher {
63 fn arm(self: &Rc<Self>) {
64 let weak: Weak<LongPressWatcher> = Rc::downgrade(self);
65 let registration = self.frame_clock.with_frame_nanos(move |now| {
66 let Some(watcher) = weak.upgrade() else {
67 return;
68 };
69 watcher.tick(now);
70 });
71 *self.registration.borrow_mut() = Some(registration);
72 crate::request_render_invalidation();
75 }
76
77 fn tick(self: Rc<Self>, now: u64) {
78 self.registration.borrow_mut().take();
79 let Some(press) = self.controller.metrics().and_then(|m| m.press) else {
80 return; };
82 let moved = (press.position.x - self.start.x)
83 .abs()
84 .max((press.position.y - self.start.y).abs());
85 if (press.start.x - self.start.x).abs() > 0.5
86 || (press.start.y - self.start.y).abs() > 0.5
87 || moved > TEXT_LONG_PRESS_SLOP
88 {
89 return; }
91 let start = match self.start_nanos.get() {
92 Some(value) => value,
93 None => {
94 self.start_nanos.set(Some(now));
95 now
96 }
97 };
98 if now.saturating_sub(start) < TEXT_LONG_PRESS_MS * 1_000_000 {
99 self.arm();
100 return;
101 }
102 self.controller.claim_gesture();
106 let Some(metrics) = self.controller.metrics() else {
107 return;
108 };
109 let text = self.state.text();
110 let offset = window_pos_to_offset(&text, &self.style, &metrics, self.start, 0.0);
111 let (word_start, word_end) = crate::word_boundaries::find_word_boundaries(&text, offset);
112 self.state.edit(|buffer| {
113 buffer.select(TextRange::new(word_start, word_end));
114 });
115 crate::request_render_invalidation();
116 }
117}
118
119fn handle_tip_window_pos(
125 text: &str,
126 style: &TextStyle,
127 metrics: &TextFieldHandleMetrics,
128 offset: usize,
129 affinity: LineAffinity,
130) -> Point {
131 let offset = offset.min(text.len());
132 let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
136 text,
137 style,
138 None,
139 metrics.wrap_width,
140 offset,
141 affinity,
142 );
143 let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
144 Point {
145 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
146 y: metrics.node_origin.y
149 + metrics.padding_top
150 + line_index as f32 * metrics.line_height
151 + metrics.glyph_box.0
152 + metrics.glyph_box.1,
153 }
154}
155
156fn window_pos_to_offset(
163 text: &str,
164 style: &TextStyle,
165 metrics: &TextFieldHandleMetrics,
166 window_pos: Point,
167 y_bias: f32,
168) -> usize {
169 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
170 + metrics.scroll_offset)
171 .max(0.0);
172 let local_y = (window_pos.y + y_bias
175 - 0.5 * metrics.line_height
176 - metrics.node_origin.y
177 - metrics.padding_top)
178 .max(0.0);
179 crate::text::offset_for_position_wrapped(
184 text,
185 style,
186 None,
187 metrics.wrap_width,
188 metrics.line_height,
189 local_x,
190 local_y,
191 )
192}
193#[composable]
211pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
212 BasicTextFieldWithOptions(
213 state,
214 modifier,
215 BasicTextFieldOptions {
216 text_style: style,
217 ..BasicTextFieldOptions::default()
218 },
219 )
220}
221
222#[derive(Debug, Clone, PartialEq)]
224pub struct BasicTextFieldOptions {
225 pub text_style: TextStyle,
227 pub cursor_color: Color,
229 pub line_limits: TextFieldLineLimits,
231}
232
233impl Default for BasicTextFieldOptions {
234 fn default() -> Self {
235 Self {
236 text_style: TextStyle::default(),
237 cursor_color: Color(0.0, 0.478, 1.0, 1.0),
240 line_limits: TextFieldLineLimits::default(),
241 }
242 }
243}
244
245#[composable]
249pub fn BasicTextFieldWithOptions(
250 state: TextFieldState,
251 modifier: Modifier,
252 options: BasicTextFieldOptions,
253) -> NodeId {
254 let _text = state.text();
257 let _selection = state.selection();
258
259 let controller =
263 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
264
265 let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
267 .with_cursor_color(options.cursor_color)
268 .with_line_limits(options.line_limits)
269 .with_handle_controller(controller.clone());
270
271 let text_field_modifier = modifier_element(text_field_element);
273 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
274 let combined_modifier = modifier.then(final_modifier);
275
276 let node = Layout(
279 combined_modifier,
280 EmptyMeasurePolicy,
281 || {}, );
283
284 BringCaretIntoView(
289 state.clone(),
290 options.text_style.clone(),
291 controller.clone(),
292 );
293
294 SelectionHandles(state, options.text_style, controller, options.cursor_color);
301
302 node
303}
304
305fn caret_window_rect(
309 text: &str,
310 style: &TextStyle,
311 metrics: &TextFieldHandleMetrics,
312 offset: usize,
313) -> Rect {
314 let tip = handle_tip_window_pos(text, style, metrics, offset, LineAffinity::Upstream);
317 Rect {
318 x: tip.x,
319 y: tip.y - metrics.glyph_box.1,
320 width: 2.0,
321 height: metrics.glyph_box.1,
322 }
323}
324
325#[composable]
335fn BringCaretIntoView(
336 state: TextFieldState,
337 style: TextStyle,
338 controller: TextFieldHandleController,
339) {
340 let Some(metrics) = controller.metrics() else {
341 return;
342 };
343 let ime_bottom = local_ime_insets().current().bottom;
346 let responder = local_bring_into_view_responder().current();
347
348 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
349 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
350
351 if !metrics.focused {
352 previous.set(None);
355 return;
356 }
357 let Some(responder) = responder else {
358 return;
359 };
360
361 let text = state.text();
362 let selection = state.selection();
363 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
364
365 let key = (
369 selection.start,
370 selection.end,
371 (ime_bottom * 4.0).round() as i64,
372 );
373 SideEffect(move || {
374 if previous.get() != Some(key) {
375 previous.set(Some(key));
376 responder.bring_into_view(caret, ime_bottom);
377 }
378 });
379}
380
381#[composable]
386fn SelectionHandles(
387 state: TextFieldState,
388 style: TextStyle,
389 controller: TextFieldHandleController,
390 accent: Color,
391) {
392 let selection = state.selection();
393 let current_range = (selection.min(), selection.max());
394
395 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
399 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
404 let caret_menu_offset: Rc<Cell<usize>> =
407 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
408 let previous_range: Rc<Cell<(usize, usize)>> =
409 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
410 {
411 let previous_range = Rc::clone(&previous_range);
412 SideEffect(move || {
413 if previous_range.get() != current_range {
414 previous_range.set(current_range);
415 menu_open.set(true);
416 }
417 });
418 }
419 {
422 let caret_menu_offset = Rc::clone(&caret_menu_offset);
423 let caret_start = selection.start;
424 SideEffect(move || {
425 if caret_menu_open.value()
426 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
427 {
428 caret_menu_open.set(false);
429 }
430 });
431 }
432
433 let Some(metrics) = controller.metrics() else {
434 return;
435 };
436 if !metrics.focused || !metrics.direct_manipulation {
437 return;
438 }
439
440 let text = state.text();
441
442 let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
448 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
449 let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
450 remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
451 match metrics.press {
452 Some(press) => {
453 let key = (press.start.x.to_bits(), press.start.y.to_bits());
454 if press_watcher.get() != Some(key) {
455 press_watcher.set(Some(key));
456 let watcher = Rc::new(LongPressWatcher {
457 controller: controller.clone(),
458 state: state.clone(),
459 style: style.clone(),
460 start: press.start,
461 start_nanos: Cell::new(None),
462 registration: RefCell::new(None),
463 frame_clock: cranpose_core::with_current_composer(|composer| {
464 composer.runtime_handle()
465 })
466 .frame_clock(),
467 });
468 watcher.arm();
469 *press_watcher_ref.borrow_mut() = Some(watcher);
470 }
471 }
472 None => {
473 press_watcher.set(None);
474 press_watcher_ref.borrow_mut().take();
475 }
476 }
477
478 let drag_pos: MutableState<Option<Point>> =
482 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
483 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
486 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
487
488 if selection.collapsed() {
489 let tip = handle_tip_window_pos(
491 &text,
492 &style,
493 &metrics,
494 selection.start,
495 LineAffinity::Upstream,
496 );
497 let on_drag = drag_caret_closure(
498 state.clone(),
499 style.clone(),
500 controller.clone(),
501 Rc::clone(&drag_bias),
502 );
503 let open_caret_menu = {
506 let caret_menu_offset = Rc::clone(&caret_menu_offset);
507 let state = state.clone();
508 move || {
509 caret_menu_offset.set(state.selection().start);
510 caret_menu_open.set(true);
511 }
512 };
513 let on_tap = open_caret_menu.clone();
514 let on_long_press = open_caret_menu;
515 let grab_bias = Rc::clone(&drag_bias);
516 let end_bias = Rc::clone(&drag_bias);
517 let tip_y = tip.y;
518 SelectionHandle(
519 HandleKind::Cursor,
520 tip,
521 metrics.glyph_box.1,
522 HANDLE_RADIUS,
523 accent,
524 move |pos| {
525 track_handle_grab(&grab_bias, tip_y, pos.y);
526 drag_pos.set(Some(pos));
527 on_drag(pos);
528 },
529 move || {
530 drag_pos.set(None);
531 end_bias.set(None);
532 },
533 on_long_press,
534 on_tap,
535 );
536
537 if caret_menu_open.value() {
542 let can_paste = clipboard_read_text().is_some();
543 let can_undo = state.can_undo();
544 let can_redo = state.can_redo();
545 let undo_state = state.clone();
546 let redo_state = state.clone();
547 CaretActionMenu(
548 tip.x,
549 tip.y - metrics.glyph_box.1,
550 drag_pos.value().is_none(),
551 can_paste,
552 can_undo,
553 can_redo,
554 move || {
555 if let Some(text) = clipboard_read_text() {
556 dispatch_paste(&text);
557 }
558 caret_menu_open.set(false);
559 },
560 move || {
561 dispatch_select_all();
562 caret_menu_open.set(false);
563 },
564 move || {
565 undo_state.undo();
566 crate::request_render_invalidation();
567 caret_menu_open.set(false);
568 },
569 move || {
570 redo_state.redo();
571 crate::request_render_invalidation();
572 caret_menu_open.set(false);
573 },
574 );
575 }
576 } else {
577 let start = selection.min();
579 let end = selection.max();
580 let start_tip =
581 handle_tip_window_pos(&text, &style, &metrics, start, LineAffinity::Downstream);
582 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end, LineAffinity::Upstream);
583
584 let on_drag_start = drag_edge_closure(
585 HandleKind::SelectionStart,
586 state.clone(),
587 style.clone(),
588 controller.clone(),
589 Rc::clone(&drag_bias),
590 );
591 let grab_bias = Rc::clone(&drag_bias);
592 let end_bias = Rc::clone(&drag_bias);
593 let start_tip_y = start_tip.y;
594 SelectionHandle(
595 HandleKind::SelectionStart,
596 start_tip,
597 metrics.glyph_box.1,
598 HANDLE_RADIUS,
599 accent,
600 move |pos| {
601 track_handle_grab(&grab_bias, start_tip_y, pos.y);
602 drag_pos.set(Some(pos));
603 on_drag_start(pos);
604 },
605 move || {
606 drag_pos.set(None);
607 end_bias.set(None);
608 },
609 move || menu_open.set(true),
613 move || menu_open.set(true),
615 );
616
617 let on_drag_end = drag_edge_closure(
618 HandleKind::SelectionEnd,
619 state.clone(),
620 style.clone(),
621 controller.clone(),
622 Rc::clone(&drag_bias),
623 );
624 let grab_bias = Rc::clone(&drag_bias);
625 let end_bias = Rc::clone(&drag_bias);
626 let end_tip_y = end_tip.y;
627 SelectionHandle(
628 HandleKind::SelectionEnd,
629 end_tip,
630 metrics.glyph_box.1,
631 HANDLE_RADIUS,
632 accent,
633 move |pos| {
634 track_handle_grab(&grab_bias, end_tip_y, pos.y);
635 drag_pos.set(Some(pos));
636 on_drag_end(pos);
637 },
638 move || {
639 drag_pos.set(None);
640 end_bias.set(None);
641 },
642 move || menu_open.set(true),
643 move || menu_open.set(true),
644 );
645
646 if menu_open.value() {
652 let can_paste = clipboard_read_text().is_some();
653 let slide_point = if controller.gesture_claimed() {
656 metrics.press.map(|press| press.position)
657 } else {
658 None
659 };
660 TextSelectionMenu(
661 (start_tip.x + end_tip.x) * 0.5,
663 start_tip.y - metrics.glyph_box.1,
664 drag_pos.value().is_none(),
665 slide_point,
666 can_paste,
667 move || {
668 if let Some(text) = dispatch_copy() {
669 clipboard_write_text(&text);
670 }
671 menu_open.set(false);
672 },
673 move || {
674 if let Some(text) = dispatch_cut() {
675 clipboard_write_text(&text);
676 }
677 menu_open.set(false);
678 },
679 move || {
680 if let Some(text) = clipboard_read_text() {
681 dispatch_paste(&text);
682 }
683 menu_open.set(false);
684 },
685 move || {
686 dispatch_select_all();
687 menu_open.set(false);
688 },
689 );
690 }
691 }
692
693 let loupe_target = drag_pos.value().and_then(|finger| {
700 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
701 let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
702 let line_bottom =
705 handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
706 loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
707 });
708 SelectionLoupe(loupe_target);
709}
710
711fn track_handle_grab(
712 drag_bias: &Cell<Option<HandleGrabOffset>>,
713 handle_tip_y: f32,
714 finger_y: f32,
715) -> f32 {
716 let mut grab = drag_bias
717 .get()
718 .unwrap_or_else(|| HandleGrabOffset::begin(handle_tip_y, finger_y));
719 let bias = grab.track(finger_y);
720 drag_bias.set(Some(grab));
721 bias
722}
723
724fn drag_caret_closure(
728 state: TextFieldState,
729 style: TextStyle,
730 controller: TextFieldHandleController,
731 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
732) -> Rc<dyn Fn(Point)> {
733 Rc::new(move |window_pos: Point| {
734 let Some(metrics) = controller.metrics() else {
735 return;
736 };
737 let text = state.text();
738 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
739 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
740 state.set_selection(TextRange::new(offset, offset));
741 crate::request_render_invalidation();
742 })
743}
744
745fn drag_edge_closure(
749 dragged: HandleKind,
750 state: TextFieldState,
751 style: TextStyle,
752 controller: TextFieldHandleController,
753 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
754) -> Rc<dyn Fn(Point)> {
755 Rc::new(move |window_pos: Point| {
756 let Some(metrics) = controller.metrics() else {
757 return;
758 };
759 let text = state.text();
760 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
761 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
762 let selection = state.selection();
763 let fixed_edge = match dragged {
764 HandleKind::SelectionStart => selection.max(),
765 _ => selection.min(),
766 };
767 let (min, max) =
768 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
769 state.set_selection(TextRange::new(min, max));
770 crate::request_render_invalidation();
771 })
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
778 use std::sync::Arc;
779
780 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
782 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
783 f()
784 }
785
786 fn render_collapsed_handles(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
790 use crate::layout::LayoutEngine;
791 use crate::renderer::HeadlessRenderer;
792 use crate::widgets::PopupHost;
793 use cranpose_ui_graphics::Size;
794
795 let mut composition = Composition::new(MemoryApplier::new());
796 let key = location_key(file!(), line!(), column!());
797 let state = TextFieldState::new("hello world");
798
799 let mut content = {
800 let state = state.clone();
801 move || {
802 let state = state.clone();
803 PopupHost(move || {
804 let controller = TextFieldHandleController::new();
805 controller.publish(TextFieldHandleMetrics {
806 focused: true,
807 direct_manipulation,
808 node_origin: Point { x: 0.0, y: 10.0 },
809 padding_left: 0.0,
810 padding_top: 0.0,
811 scroll_offset: 0.0,
812 line_height: 18.0,
813 glyph_box: (0.0, 18.0),
814 wrap_width: None,
815 press: None,
816 });
817 SelectionHandles(
818 state.clone(),
819 TextStyle::default(),
820 controller,
821 Color(0.0, 0.478, 1.0, 1.0),
822 );
823 });
824 }
825 };
826
827 composition.render(key, &mut content).expect("render");
828 for _ in 0..16 {
829 if !composition.should_render() {
830 break;
831 }
832 composition.reconcile(key, &mut content).expect("reconcile");
833 }
834 let root = composition.root().expect("root");
835 let handle = composition.runtime_handle();
836 let mut applier = composition.applier_mut();
837 applier.set_runtime_handle(handle);
838 let layout = applier
839 .compute_layout(
840 root,
841 Size {
842 width: 400.0,
843 height: 400.0,
844 },
845 )
846 .expect("layout");
847 applier.clear_runtime_handle();
848 drop(applier);
849 HeadlessRenderer::new().render(&layout)
850 }
851
852 fn render_range_menu(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
855 use crate::layout::LayoutEngine;
856 use crate::renderer::HeadlessRenderer;
857 use crate::widgets::PopupHost;
858 use cranpose_ui_graphics::Size;
859
860 let mut composition = Composition::new(MemoryApplier::new());
861 let key = location_key(file!(), line!(), column!());
862 let state = TextFieldState::new("hello world");
863
864 let mut content = {
865 let state = state.clone();
866 move || {
867 let state = state.clone();
868 PopupHost(move || {
869 let controller = TextFieldHandleController::new();
870 if state.selection() != TextRange::new(0, 5) {
871 state.set_selection(TextRange::new(0, 5));
872 }
873 controller.publish(TextFieldHandleMetrics {
874 focused: true,
875 direct_manipulation,
876 node_origin: Point { x: 0.0, y: 40.0 },
877 padding_left: 0.0,
878 padding_top: 0.0,
879 scroll_offset: 0.0,
880 line_height: 18.0,
881 glyph_box: (0.0, 18.0),
882 wrap_width: None,
883 press: None,
884 });
885 SelectionHandles(
886 state.clone(),
887 TextStyle::default(),
888 controller,
889 Color(0.0, 0.478, 1.0, 1.0),
890 );
891 });
892 }
893 };
894
895 composition.render(key, &mut content).expect("render");
896 for _ in 0..16 {
897 if !composition.should_render() {
898 break;
899 }
900 composition.reconcile(key, &mut content).expect("reconcile");
901 }
902 let root = composition.root().expect("root");
903 let handle = composition.runtime_handle();
904 let mut applier = composition.applier_mut();
905 applier.set_runtime_handle(handle);
906 let layout = applier
907 .compute_layout(
908 root,
909 Size {
910 width: 400.0,
911 height: 400.0,
912 },
913 )
914 .expect("layout");
915 applier.clear_runtime_handle();
916 drop(applier);
917 HeadlessRenderer::new().render(&layout)
918 }
919
920 fn render_range_menu_subcomposed(
926 direct_manipulation: bool,
927 ) -> crate::renderer::RecordedRenderScene {
928 use crate::layout::LayoutEngine;
929 use crate::renderer::HeadlessRenderer;
930 use crate::widgets::{BoxWithConstraints, PopupHost};
931 use cranpose_ui_graphics::Size;
932
933 let mut composition = Composition::new(MemoryApplier::new());
934 let key = location_key(file!(), line!(), column!());
935 let state = TextFieldState::new("hello world");
936
937 let mut content = {
938 let state = state.clone();
939 move || {
940 let state = state.clone();
941 PopupHost(move || {
942 let state = state.clone();
943 BoxWithConstraints(
944 Modifier::empty().size(Size {
945 width: 300.0,
946 height: 300.0,
947 }),
948 move |_scope| {
949 let controller = TextFieldHandleController::new();
950 if state.selection() != TextRange::new(0, 5) {
951 state.set_selection(TextRange::new(0, 5));
952 }
953 controller.publish(TextFieldHandleMetrics {
954 focused: true,
955 direct_manipulation,
956 node_origin: Point { x: 0.0, y: 40.0 },
957 padding_left: 0.0,
958 padding_top: 0.0,
959 scroll_offset: 0.0,
960 line_height: 18.0,
961 glyph_box: (0.0, 18.0),
962 wrap_width: None,
963 press: None,
964 });
965 SelectionHandles(
966 state.clone(),
967 TextStyle::default(),
968 controller,
969 Color(0.0, 0.478, 1.0, 1.0),
970 );
971 },
972 );
973 });
974 }
975 };
976
977 composition.render(key, &mut content).expect("render");
978 let root = composition.root().expect("root");
979 let handle = composition.runtime_handle();
980 let mut scene = None;
981 for _ in 0..8 {
985 for _ in 0..16 {
986 if !composition.should_render() {
987 break;
988 }
989 composition.reconcile(key, &mut content).expect("reconcile");
990 }
991 let mut applier = composition.applier_mut();
992 applier.set_runtime_handle(handle.clone());
993 let layout = applier
994 .compute_layout(
995 root,
996 Size {
997 width: 400.0,
998 height: 400.0,
999 },
1000 )
1001 .expect("layout");
1002 applier.clear_runtime_handle();
1003 drop(applier);
1004 scene = Some(HeadlessRenderer::new().render(&layout));
1005 }
1006 scene.expect("scene")
1007 }
1008
1009 fn render_range_menu_lazy_column(
1016 direct_manipulation: bool,
1017 ) -> crate::renderer::RecordedRenderScene {
1018 use crate::layout::LayoutEngine;
1019 use crate::renderer::HeadlessRenderer;
1020 use crate::widgets::PopupHost;
1021 use crate::{LazyColumn, LazyColumnSpec};
1022 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
1023 use cranpose_ui_graphics::Size;
1024
1025 let mut composition = Composition::new(MemoryApplier::new());
1026 let key = location_key(file!(), line!(), column!());
1027 let state = TextFieldState::new("hello world");
1028
1029 let mut content = {
1030 let state = state.clone();
1031 move || {
1032 let state = state.clone();
1033 PopupHost(move || {
1034 let state = state.clone();
1035 let list_state = remember_lazy_list_state();
1036 LazyColumn(
1037 Modifier::empty().size(Size {
1038 width: 300.0,
1039 height: 300.0,
1040 }),
1041 list_state,
1042 LazyColumnSpec::default(),
1043 move |scope| {
1044 let state = state.clone();
1045 scope.items(
1046 1,
1047 None::<fn(usize) -> u64>,
1048 None::<fn(usize) -> u64>,
1049 move |_index| {
1050 let controller = TextFieldHandleController::new();
1051 if state.selection() != TextRange::new(0, 5) {
1052 state.set_selection(TextRange::new(0, 5));
1053 }
1054 controller.publish(TextFieldHandleMetrics {
1055 focused: true,
1056 direct_manipulation,
1057 node_origin: Point { x: 0.0, y: 40.0 },
1058 padding_left: 0.0,
1059 padding_top: 0.0,
1060 scroll_offset: 0.0,
1061 line_height: 18.0,
1062 glyph_box: (0.0, 18.0),
1063 wrap_width: None,
1064 press: None,
1065 });
1066 SelectionHandles(
1067 state.clone(),
1068 TextStyle::default(),
1069 controller,
1070 Color(0.0, 0.478, 1.0, 1.0),
1071 );
1072 },
1073 );
1074 },
1075 );
1076 });
1077 }
1078 };
1079
1080 composition.render(key, &mut content).expect("render");
1081 let root = composition.root().expect("root");
1082 let handle = composition.runtime_handle();
1083 let mut scene = None;
1084 for _ in 0..8 {
1085 for _ in 0..16 {
1086 if !composition.should_render() {
1087 break;
1088 }
1089 composition.reconcile(key, &mut content).expect("reconcile");
1090 }
1091 let mut applier = composition.applier_mut();
1092 applier.set_runtime_handle(handle.clone());
1093 let layout = applier
1094 .compute_layout(
1095 root,
1096 Size {
1097 width: 400.0,
1098 height: 400.0,
1099 },
1100 )
1101 .expect("layout");
1102 applier.clear_runtime_handle();
1103 drop(applier);
1104 scene = Some(HeadlessRenderer::new().render(&layout));
1105 }
1106 scene.expect("scene")
1107 }
1108
1109 #[test]
1118 fn field_window_origin_follows_vertical_scroll() {
1119 use crate::layout::policies::EmptyMeasurePolicy;
1120 use crate::layout::{LayoutBox, LayoutEngine};
1121 use crate::renderer::HeadlessRenderer;
1122 use crate::scroll::ScrollState;
1123 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
1124 use cranpose_core::{remember, Key};
1125 use cranpose_foundation::modifier_element;
1126 use cranpose_ui_graphics::Size;
1127 use std::cell::RefCell;
1128
1129 let _app_context = crate::render_state::app_context_test_scope();
1130
1131 let mut composition = Composition::new(MemoryApplier::new());
1134 let state = TextFieldState::new("hello world");
1135 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
1136 Rc::new(RefCell::new(None));
1137 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1140
1141 let spacer_before = 200.0_f32;
1142 let mut content = {
1143 let state = state.clone();
1144 let controller_slot = Rc::clone(&controller_slot);
1145 let scroll_slot = Rc::clone(&scroll_slot);
1146 move || {
1147 let state = state.clone();
1148 let controller_slot = Rc::clone(&controller_slot);
1149 let scroll_slot = Rc::clone(&scroll_slot);
1150 PopupHost(move || {
1151 let controller = remember(TextFieldHandleController::new)
1152 .with(TextFieldHandleController::clone);
1153 *controller_slot.borrow_mut() = Some(controller.clone());
1154 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
1155 *scroll_slot.borrow_mut() = Some(scroll.clone());
1156 let state = state.clone();
1157 let controller = controller.clone();
1158 Column(
1159 Modifier::empty()
1160 .size(Size {
1161 width: 300.0,
1162 height: 150.0,
1163 })
1164 .vertical_scroll(scroll.clone(), false),
1165 ColumnSpec::default(),
1166 move || {
1167 Spacer(Size {
1168 width: 300.0,
1169 height: spacer_before,
1170 });
1171 let element =
1172 TextFieldElement::new(state.clone(), TextStyle::default())
1173 .with_handle_controller(controller.clone());
1174 let field_modifier =
1175 Modifier::from_parts(vec![modifier_element(element)]);
1176 Layout(field_modifier, EmptyMeasurePolicy, || {});
1177 Spacer(Size {
1178 width: 300.0,
1179 height: 400.0,
1180 });
1181 },
1182 );
1183 });
1184 }
1185 };
1186
1187 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1189 if node
1190 .node_data
1191 .modifier_slices()
1192 .text_field_window_origin()
1193 .is_some()
1194 {
1195 return Some(node.rect);
1196 }
1197 node.children.iter().find_map(find_field_rect)
1198 }
1199
1200 fn layout_and_read(
1201 composition: &mut Composition<MemoryApplier>,
1202 key: Key,
1203 content: &mut dyn FnMut(),
1204 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1205 ) -> (Point, f32) {
1206 for _ in 0..16 {
1207 if !composition.should_render() {
1208 break;
1209 }
1210 composition
1211 .reconcile(key, &mut *content)
1212 .expect("reconcile");
1213 }
1214 let root = composition.root().expect("root");
1215 let handle = composition.runtime_handle();
1216 let mut applier = composition.applier_mut();
1217 applier.set_runtime_handle(handle);
1218 let layout = applier
1219 .compute_layout(
1220 root,
1221 cranpose_ui_graphics::Size {
1222 width: 400.0,
1223 height: 600.0,
1224 },
1225 )
1226 .expect("layout");
1227 applier.clear_runtime_handle();
1228 drop(applier);
1229 let _ = HeadlessRenderer::new().render(&layout);
1230 let field_y = find_field_rect(layout.root()).expect("field placed").y;
1231 let node_origin = controller_slot
1232 .borrow()
1233 .as_ref()
1234 .expect("controller")
1235 .metrics()
1236 .expect("metrics published")
1237 .node_origin;
1238 (node_origin, field_y)
1239 }
1240
1241 let key = location_key(file!(), line!(), column!());
1242 composition.render(key, &mut content).expect("render");
1243
1244 let (origin0, field_y0) =
1245 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1246 assert!(
1249 (origin0.y - field_y0).abs() < 0.5,
1250 "published node_origin.y {} must equal the field's placed window-y {}",
1251 origin0.y,
1252 field_y0
1253 );
1254 assert!(
1255 origin0.y >= spacer_before - 0.5,
1256 "field should start at/after the {spacer_before}px leading spacer, got {}",
1257 origin0.y
1258 );
1259
1260 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
1263 scroll.scroll_to(50.0);
1264 assert!(
1265 scroll.value() >= 49.5,
1266 "test setup: content must be tall enough to scroll 50px (got {})",
1267 scroll.value()
1268 );
1269 let (origin1, field_y1) =
1270 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1271 assert!(
1272 (origin1.y - field_y1).abs() < 0.5,
1273 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1274 origin1.y,
1275 field_y1
1276 );
1277 assert!(
1278 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1279 "scrolling 50px must shift the published field origin up by 50px: \
1280 before {}, after {} (expected {})",
1281 origin0.y,
1282 origin1.y,
1283 origin0.y - 50.0
1284 );
1285 }
1286
1287 #[test]
1293 fn window_offset_roundtrip_holds_under_scroll_offset() {
1294 let _app_context = crate::render_state::app_context_test_scope();
1295 let text = "hello world";
1296 let style = TextStyle::default();
1297 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1299 let metrics = TextFieldHandleMetrics {
1300 focused: true,
1301 direct_manipulation: true,
1302 node_origin,
1303 padding_left: 4.0,
1304 padding_top: 3.0,
1305 scroll_offset: 0.0,
1306 line_height: 18.0,
1307 glyph_box: (0.0, 18.0),
1308 wrap_width: None,
1309 press: None,
1310 };
1311 for offset in 0..=text.len() {
1312 if !text.is_char_boundary(offset) {
1313 continue;
1314 }
1315 let tip =
1318 handle_tip_window_pos(text, &style, &metrics, offset, LineAffinity::Downstream);
1319 let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1320 assert_eq!(
1321 resolved, offset,
1322 "finger at the tip of offset {offset} must map back to it \
1323 under origin {node_origin:?}, got {resolved}"
1324 );
1325 }
1326 }
1327 }
1328
1329 #[test]
1337 fn shared_wrap_boundary_anchors_by_handle_affinity() {
1338 let _app_context = crate::render_state::app_context_test_scope();
1339 with_test_runtime(|| {
1340 let text = "aaaaaaaaaaaaaaaaaaaaaaaa";
1341 let style = TextStyle::default();
1342 let annotated = crate::text::AnnotatedString::from(text);
1343 let full = crate::text::measure_text(&annotated, &style);
1344 let wrap_width = full.width / 3.0;
1346 let ranges = crate::text::wrapped_line_ranges(
1347 None,
1348 &annotated,
1349 &style,
1350 crate::text::TextLayoutOptions::default(),
1351 Some(wrap_width),
1352 );
1353 assert!(
1354 ranges.len() >= 2,
1355 "test setup: text must wrap, got {ranges:?}"
1356 );
1357 let boundary = ranges[1].start;
1358 assert_eq!(
1359 ranges[0].end, boundary,
1360 "test setup: a mid-word wrap must share its boundary byte, got {ranges:?}"
1361 );
1362
1363 let line_height = 20.0;
1364 let metrics = TextFieldHandleMetrics {
1365 focused: true,
1366 direct_manipulation: true,
1367 node_origin: Point { x: 0.0, y: 0.0 },
1368 padding_left: 0.0,
1369 padding_top: 0.0,
1370 scroll_offset: 0.0,
1371 line_height,
1372 glyph_box: (0.0, line_height),
1373 wrap_width: Some(wrap_width),
1374 press: None,
1375 };
1376
1377 let end_tip =
1378 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Upstream);
1379 assert!(
1380 (end_tip.y - line_height).abs() < 0.5,
1381 "end handle must sit on the UPPER line's bottom ({line_height}), got y={}",
1382 end_tip.y
1383 );
1384 assert!(
1385 end_tip.x > 1.0,
1386 "end handle must sit at the upper line's right edge, got x={}",
1387 end_tip.x
1388 );
1389
1390 let start_tip =
1391 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Downstream);
1392 assert!(
1393 (start_tip.y - 2.0 * line_height).abs() < 0.5,
1394 "start handle must sit on the LOWER line's bottom ({}), got y={}",
1395 2.0 * line_height,
1396 start_tip.y
1397 );
1398 assert!(
1399 start_tip.x.abs() < 0.5,
1400 "start handle must sit at the lower line's left edge, got x={}",
1401 start_tip.x
1402 );
1403
1404 let mut grab = HandleGrabOffset::begin(end_tip.y, end_tip.y);
1410 for finger_y in [
1411 end_tip.y,
1412 end_tip.y + 8.0,
1413 end_tip.y + 32.0,
1414 end_tip.y + 80.0,
1415 ] {
1416 let bias = grab.track(finger_y);
1417 let resolved = window_pos_to_offset(
1418 text,
1419 &style,
1420 &metrics,
1421 Point {
1422 x: end_tip.x,
1423 y: finger_y,
1424 },
1425 bias,
1426 );
1427 let resolved_tip =
1428 handle_tip_window_pos(text, &style, &metrics, resolved, LineAffinity::Upstream);
1429 let target_tip_y =
1430 (finger_y + bias).clamp(line_height, ranges.len() as f32 * line_height);
1431 assert!(
1432 (resolved_tip.y - target_tip_y).abs() <= line_height * 0.5 + 0.5,
1433 "finger y={finger_y}, bias={bias} resolved to offset {resolved} at y={}, expected the nearest visual-line bottom to {}",
1434 resolved_tip.y,
1435 target_tip_y,
1436 );
1437 }
1438 })
1439 }
1440
1441 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1442 use crate::renderer::RenderOp;
1443 scene
1444 .operations()
1445 .iter()
1446 .filter_map(|op| match op {
1447 RenderOp::Text { value, .. } => Some(value.clone()),
1448 _ => None,
1449 })
1450 .collect()
1451 }
1452
1453 fn render_caret_action_menu(
1457 can_paste: bool,
1458 can_undo: bool,
1459 can_redo: bool,
1460 ) -> crate::renderer::RecordedRenderScene {
1461 use crate::layout::LayoutEngine;
1462 use crate::renderer::HeadlessRenderer;
1463 use crate::widgets::PopupHost;
1464 use cranpose_ui_graphics::Size;
1465
1466 let mut composition = Composition::new(MemoryApplier::new());
1467 let key = location_key(file!(), line!(), column!());
1468
1469 let mut content = move || {
1470 PopupHost(move || {
1471 CaretActionMenu(
1472 40.0,
1473 60.0,
1474 true,
1475 can_paste,
1476 can_undo,
1477 can_redo,
1478 || {},
1479 || {},
1480 || {},
1481 || {},
1482 );
1483 });
1484 };
1485
1486 composition.render(key, &mut content).expect("render");
1487 for _ in 0..16 {
1488 if !composition.should_render() {
1489 break;
1490 }
1491 composition.reconcile(key, &mut content).expect("reconcile");
1492 }
1493 let root = composition.root().expect("root");
1494 let handle = composition.runtime_handle();
1495 let mut applier = composition.applier_mut();
1496 applier.set_runtime_handle(handle);
1497 let layout = applier
1498 .compute_layout(
1499 root,
1500 Size {
1501 width: 400.0,
1502 height: 400.0,
1503 },
1504 )
1505 .expect("layout");
1506 applier.clear_runtime_handle();
1507 drop(applier);
1508 HeadlessRenderer::new().render(&layout)
1509 }
1510
1511 #[test]
1515 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1516 let _app_context = crate::render_state::app_context_test_scope();
1517
1518 let all = text_values(&render_caret_action_menu(true, true, true));
1519 for label in ["Paste", "Select all", "Undo", "Redo"] {
1520 assert!(
1521 all.iter().any(|t| t == label),
1522 "caret menu should show {label:?}, got {all:?}"
1523 );
1524 }
1525
1526 let bare = text_values(&render_caret_action_menu(false, false, false));
1528 assert!(
1529 bare.iter().any(|t| t == "Select all"),
1530 "Select all is always available, got {bare:?}"
1531 );
1532 assert!(
1533 !bare
1534 .iter()
1535 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1536 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1537 );
1538 }
1539
1540 #[test]
1541 fn context_menu_shows_for_pointer_selection_on_every_platform() {
1542 let _app_context = crate::render_state::app_context_test_scope();
1543
1544 let touch = text_values(&render_range_menu(true));
1545 assert!(
1546 touch.iter().any(|t| t == "Copy"),
1547 "touch selection should show the Copy menu item, got {touch:?}"
1548 );
1549 assert!(
1550 touch.iter().any(|t| t == "Cut"),
1551 "expected Cut, got {touch:?}"
1552 );
1553 assert!(
1554 touch.iter().any(|t| t == "Select all"),
1555 "expected Select all, got {touch:?}"
1556 );
1557
1558 let mouse = text_values(&render_range_menu(true));
1559 assert!(
1560 mouse.iter().any(|t| t == "Copy"),
1561 "mouse selection must expose the same direct-manipulation menu, got {mouse:?}"
1562 );
1563 let keyboard = text_values(&render_range_menu(false));
1564 assert!(
1565 !keyboard.iter().any(|t| t == "Copy"),
1566 "keyboard-only focus must keep a clean caret, got {keyboard:?}"
1567 );
1568 }
1569
1570 #[test]
1571 fn selection_handles_and_menu_survive_subcomposition() {
1572 let _app_context = crate::render_state::app_context_test_scope();
1578 let scene = render_range_menu_subcomposed(true);
1579
1580 let texts = text_values(&scene);
1581 assert!(
1582 texts.iter().any(|t| t == "Copy"),
1583 "a touch selection inside a subcomposition should show the Copy menu \
1584 item through the host, got {texts:?}"
1585 );
1586 assert!(
1587 texts.iter().any(|t| t == "Select all"),
1588 "expected Select all inside a subcomposition, got {texts:?}"
1589 );
1590 assert_eq!(
1591 image_count(&scene),
1592 2,
1593 "a touch range selection should show two finger teardrop handles in \
1594 the overlay across the subcomposition boundary"
1595 );
1596 }
1597
1598 #[test]
1599 fn selection_handles_and_menu_survive_lazy_column_item() {
1600 let _app_context = crate::render_state::app_context_test_scope();
1607 let scene = render_range_menu_lazy_column(true);
1608
1609 let texts = text_values(&scene);
1610 assert!(
1611 texts.iter().any(|t| t == "Copy"),
1612 "a touch selection inside a LazyColumn item should show the Copy menu \
1613 item through the host, got {texts:?}"
1614 );
1615 assert!(
1616 texts.iter().any(|t| t == "Select all"),
1617 "expected Select all inside a LazyColumn item, got {texts:?}"
1618 );
1619 assert_eq!(
1620 image_count(&scene),
1621 2,
1622 "a touch range selection should show two finger teardrop handles in \
1623 the overlay across the LazyColumn item subcomposition boundary"
1624 );
1625 }
1626
1627 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1628 use crate::renderer::RenderOp;
1629 use cranpose_ui_graphics::DrawPrimitive;
1630 scene
1631 .operations()
1632 .iter()
1633 .filter(|op| {
1634 matches!(
1635 op,
1636 RenderOp::Primitive {
1637 primitive: DrawPrimitive::Image { .. },
1638 ..
1639 }
1640 )
1641 })
1642 .count()
1643 }
1644
1645 #[test]
1646 fn cursor_handle_shows_for_pointer_selection_on_every_platform() {
1647 let _app_context = crate::render_state::app_context_test_scope();
1648 assert_eq!(
1649 image_count(&render_collapsed_handles(true)),
1650 1,
1651 "a touch caret should show one finger cursor handle in the overlay"
1652 );
1653 assert_eq!(
1654 image_count(&render_collapsed_handles(true)),
1655 1,
1656 "a mouse-created caret should expose its draggable handle"
1657 );
1658 assert_eq!(
1659 image_count(&render_collapsed_handles(false)),
1660 0,
1661 "keyboard-only focus should keep a clean caret"
1662 );
1663 }
1664
1665 #[test]
1666 fn basic_text_field_creates_node() {
1667 let _app_context = crate::render_state::app_context_test_scope();
1668 let mut composition = Composition::new(MemoryApplier::new());
1669 let state = TextFieldState::new("Test content");
1670
1671 let result = composition.render(location_key(file!(), line!(), column!()), {
1672 let state = state.clone();
1673 move || {
1674 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1675 }
1676 });
1677
1678 assert!(result.is_ok());
1679 assert!(composition.root().is_some());
1680 }
1681
1682 #[test]
1683 fn basic_text_field_state_updates() {
1684 let _app_context = crate::render_state::app_context_test_scope();
1685 with_test_runtime(|| {
1686 let state = TextFieldState::new("Hello");
1687 assert_eq!(state.text(), "Hello");
1688
1689 state.edit(|buffer| {
1690 buffer.place_cursor_at_end();
1691 buffer.insert("!");
1692 });
1693
1694 assert_eq!(state.text(), "Hello!");
1695 });
1696 }
1697
1698 #[test]
1707 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1708 use crate::bring_into_view::local_bring_into_view_responder;
1709 use crate::layout::LayoutEngine;
1710 use crate::renderer::HeadlessRenderer;
1711 use crate::widgets::{Box, BoxSpec, PopupHost};
1712 use crate::{LazyColumn, LazyColumnSpec};
1713 use cranpose_core::Key;
1714 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1715 use cranpose_ui_graphics::Size;
1716 use std::cell::RefCell;
1717
1718 let _app_context = crate::render_state::app_context_test_scope();
1719 let mut composition = Composition::new(MemoryApplier::new());
1720 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1721 Rc::new(RefCell::new(None));
1722 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1723
1724 let mut content = {
1727 let responder_slot = Rc::clone(&responder_slot);
1728 let state_slot = Rc::clone(&state_slot);
1729 move || {
1730 let responder_slot = Rc::clone(&responder_slot);
1731 let state_slot = Rc::clone(&state_slot);
1732 PopupHost(move || {
1733 let list_state = remember_lazy_list_state();
1734 *state_slot.borrow_mut() = Some(list_state);
1735 let responder_slot = Rc::clone(&responder_slot);
1736 LazyColumn(
1737 Modifier::empty().size(Size {
1738 width: 300.0,
1739 height: 400.0,
1740 }),
1741 list_state,
1742 LazyColumnSpec::default(),
1743 move |scope| {
1744 let responder_slot = Rc::clone(&responder_slot);
1745 scope.items(
1746 30,
1747 None::<fn(usize) -> u64>,
1748 None::<fn(usize) -> u64>,
1749 move |_index| {
1750 if responder_slot.borrow().is_none() {
1751 if let Some(r) = local_bring_into_view_responder().current()
1752 {
1753 *responder_slot.borrow_mut() = Some(r);
1754 }
1755 }
1756 Box(
1757 Modifier::empty().size(Size {
1758 width: 300.0,
1759 height: 80.0,
1760 }),
1761 BoxSpec::default(),
1762 || {},
1763 );
1764 },
1765 );
1766 },
1767 );
1768 });
1769 }
1770 };
1771
1772 fn run_layout(
1773 composition: &mut Composition<MemoryApplier>,
1774 key: Key,
1775 content: &mut dyn FnMut(),
1776 ) {
1777 for _ in 0..16 {
1778 if !composition.should_render() {
1779 break;
1780 }
1781 composition
1782 .reconcile(key, &mut *content)
1783 .expect("reconcile");
1784 }
1785 let root = composition.root().expect("root");
1786 let handle = composition.runtime_handle();
1787 let mut applier = composition.applier_mut();
1788 applier.set_runtime_handle(handle);
1789 let layout = applier
1790 .compute_layout(
1791 root,
1792 Size {
1793 width: 400.0,
1794 height: 600.0,
1795 },
1796 )
1797 .expect("layout");
1798 applier.clear_runtime_handle();
1799 drop(applier);
1800 let _ = HeadlessRenderer::new().render(&layout);
1801 }
1802
1803 let key = location_key(file!(), line!(), column!());
1804 composition.render(key, &mut content).expect("render");
1805 run_layout(&mut composition, key, &mut content);
1806
1807 let responder = responder_slot
1808 .borrow()
1809 .clone()
1810 .expect("LazyColumn provides a bring-into-view responder to its items");
1811 let list_state = state_slot.borrow().expect("list state captured");
1812 let offset0 = list_state.first_visible_item_scroll_offset();
1813 let index0 = list_state.first_visible_item_index();
1814
1815 responder.bring_into_view(
1818 Rect {
1819 x: 10.0,
1820 y: 100.0,
1821 width: 2.0,
1822 height: 20.0,
1823 },
1824 0.0,
1825 );
1826 run_layout(&mut composition, key, &mut content);
1827 assert_eq!(
1828 list_state.first_visible_item_index(),
1829 index0,
1830 "an already-visible caret must not scroll the list"
1831 );
1832 assert!(
1833 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1834 "an already-visible caret must not scroll the list"
1835 );
1836
1837 responder.bring_into_view(
1840 Rect {
1841 x: 10.0,
1842 y: 360.0,
1843 width: 2.0,
1844 height: 20.0,
1845 },
1846 250.0,
1847 );
1848 run_layout(&mut composition, key, &mut content);
1849 let scrolled_forward = list_state.first_visible_item_index() > index0
1850 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1851 assert!(
1852 scrolled_forward,
1853 "a caret behind the keyboard must scroll the list forward \
1854 (index {} -> {}, offset {:.1} -> {:.1})",
1855 index0,
1856 list_state.first_visible_item_index(),
1857 offset0,
1858 list_state.first_visible_item_scroll_offset(),
1859 );
1860 }
1861}