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 let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
495 let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
496 let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
497
498 if selection.collapsed() {
499 let tip = handle_tip_window_pos(
501 &text,
502 &style,
503 &metrics,
504 selection.start,
505 LineAffinity::Upstream,
506 );
507 let on_drag = drag_caret_closure(
508 state.clone(),
509 style.clone(),
510 controller.clone(),
511 Rc::clone(&drag_bias),
512 );
513 let open_caret_menu = {
516 let caret_menu_offset = Rc::clone(&caret_menu_offset);
517 let state = state.clone();
518 move || {
519 caret_menu_offset.set(state.selection().start);
520 caret_menu_open.set(true);
521 }
522 };
523 let on_tap = open_caret_menu.clone();
524 let on_long_press = open_caret_menu;
525 let grab_bias = Rc::clone(&drag_bias);
526 let end_bias = Rc::clone(&drag_bias);
527 cursor_tip_y.set(tip.y);
528 let tip_y = Rc::clone(&cursor_tip_y);
529 SelectionHandle(
530 HandleKind::Cursor,
531 tip,
532 metrics.glyph_box.1,
533 HANDLE_RADIUS,
534 accent,
535 move |pos| {
536 track_handle_grab(&grab_bias, tip_y.get(), pos.y);
537 drag_pos.set(Some(pos));
538 on_drag(pos);
539 },
540 move || {
541 drag_pos.set(None);
542 end_bias.set(None);
543 },
544 on_long_press,
545 on_tap,
546 );
547
548 if caret_menu_open.value() {
553 let can_paste = clipboard_read_text().is_some();
554 let can_undo = state.can_undo();
555 let can_redo = state.can_redo();
556 let undo_state = state.clone();
557 let redo_state = state.clone();
558 CaretActionMenu(
559 tip.x,
560 tip.y - metrics.glyph_box.1,
561 drag_pos.value().is_none(),
562 can_paste,
563 can_undo,
564 can_redo,
565 move || {
566 if let Some(text) = clipboard_read_text() {
567 dispatch_paste(&text);
568 }
569 caret_menu_open.set(false);
570 },
571 move || {
572 dispatch_select_all();
573 caret_menu_open.set(false);
574 },
575 move || {
576 undo_state.undo();
577 crate::request_render_invalidation();
578 caret_menu_open.set(false);
579 },
580 move || {
581 redo_state.redo();
582 crate::request_render_invalidation();
583 caret_menu_open.set(false);
584 },
585 );
586 }
587 } else {
588 let start = selection.min();
590 let end = selection.max();
591 let start_tip =
592 handle_tip_window_pos(&text, &style, &metrics, start, LineAffinity::Downstream);
593 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end, LineAffinity::Upstream);
594
595 let on_drag_start = drag_edge_closure(
596 HandleKind::SelectionStart,
597 state.clone(),
598 style.clone(),
599 controller.clone(),
600 Rc::clone(&drag_bias),
601 );
602 let grab_bias = Rc::clone(&drag_bias);
603 let end_bias = Rc::clone(&drag_bias);
604 start_tip_y.set(start_tip.y);
605 let start_tip_live = Rc::clone(&start_tip_y);
606 SelectionHandle(
607 HandleKind::SelectionStart,
608 start_tip,
609 metrics.glyph_box.1,
610 HANDLE_RADIUS,
611 accent,
612 move |pos| {
613 track_handle_grab(&grab_bias, start_tip_live.get(), pos.y);
614 drag_pos.set(Some(pos));
615 on_drag_start(pos);
616 },
617 move || {
618 drag_pos.set(None);
619 end_bias.set(None);
620 },
621 move || menu_open.set(true),
625 move || menu_open.set(true),
627 );
628
629 let on_drag_end = drag_edge_closure(
630 HandleKind::SelectionEnd,
631 state.clone(),
632 style.clone(),
633 controller.clone(),
634 Rc::clone(&drag_bias),
635 );
636 let grab_bias = Rc::clone(&drag_bias);
637 let end_bias = Rc::clone(&drag_bias);
638 end_tip_y.set(end_tip.y);
639 let end_tip_live = Rc::clone(&end_tip_y);
640 SelectionHandle(
641 HandleKind::SelectionEnd,
642 end_tip,
643 metrics.glyph_box.1,
644 HANDLE_RADIUS,
645 accent,
646 move |pos| {
647 track_handle_grab(&grab_bias, end_tip_live.get(), pos.y);
648 drag_pos.set(Some(pos));
649 on_drag_end(pos);
650 },
651 move || {
652 drag_pos.set(None);
653 end_bias.set(None);
654 },
655 move || menu_open.set(true),
656 move || menu_open.set(true),
657 );
658
659 if menu_open.value() {
665 let can_paste = clipboard_read_text().is_some();
666 let slide_point = if controller.gesture_claimed() {
669 metrics.press.map(|press| press.position)
670 } else {
671 None
672 };
673 TextSelectionMenu(
674 (start_tip.x + end_tip.x) * 0.5,
676 start_tip.y - metrics.glyph_box.1,
677 drag_pos.value().is_none(),
678 slide_point,
679 can_paste,
680 move || {
681 if let Some(text) = dispatch_copy() {
682 clipboard_write_text(&text);
683 }
684 menu_open.set(false);
685 },
686 move || {
687 if let Some(text) = dispatch_cut() {
688 clipboard_write_text(&text);
689 }
690 menu_open.set(false);
691 },
692 move || {
693 if let Some(text) = clipboard_read_text() {
694 dispatch_paste(&text);
695 }
696 menu_open.set(false);
697 },
698 move || {
699 dispatch_select_all();
700 menu_open.set(false);
701 },
702 );
703 }
704 }
705
706 let loupe_target = drag_pos.value().and_then(|finger| {
713 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
714 let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
715 let line_bottom =
718 handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
719 loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
720 });
721 SelectionLoupe(loupe_target);
722}
723
724fn track_handle_grab(
725 drag_bias: &Cell<Option<HandleGrabOffset>>,
726 handle_tip_y: f32,
727 finger_y: f32,
728) -> f32 {
729 let mut grab = drag_bias
730 .get()
731 .unwrap_or_else(|| HandleGrabOffset::begin(handle_tip_y, finger_y));
732 let bias = grab.track(finger_y);
733 drag_bias.set(Some(grab));
734 bias
735}
736
737fn drag_caret_closure(
741 state: TextFieldState,
742 style: TextStyle,
743 controller: TextFieldHandleController,
744 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
745) -> Rc<dyn Fn(Point)> {
746 Rc::new(move |window_pos: Point| {
747 let Some(metrics) = controller.metrics() else {
748 return;
749 };
750 let text = state.text();
751 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
752 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
753 state.set_selection(TextRange::new(offset, offset));
754 crate::request_render_invalidation();
755 })
756}
757
758fn drag_edge_closure(
762 dragged: HandleKind,
763 state: TextFieldState,
764 style: TextStyle,
765 controller: TextFieldHandleController,
766 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
767) -> Rc<dyn Fn(Point)> {
768 Rc::new(move |window_pos: Point| {
769 let Some(metrics) = controller.metrics() else {
770 return;
771 };
772 let text = state.text();
773 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
774 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
775 let selection = state.selection();
776 let fixed_edge = match dragged {
777 HandleKind::SelectionStart => selection.max(),
778 _ => selection.min(),
779 };
780 let (min, max) =
781 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
782 state.set_selection(TextRange::new(min, max));
783 crate::request_render_invalidation();
784 })
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 #[test]
792 fn handle_grab_bias_reads_the_live_tip_not_a_snapshot() {
793 let tip_y: Rc<Cell<f32>> = Rc::new(Cell::new(100.0));
799 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> = Rc::new(Cell::new(None));
800 let grab = {
801 let tip_y = Rc::clone(&tip_y);
802 let drag_bias = Rc::clone(&drag_bias);
803 move |finger_y: f32| track_handle_grab(&drag_bias, tip_y.get(), finger_y)
804 };
805
806 tip_y.set(148.0);
809 let bias = grab(160.0);
810 assert_eq!(
811 bias,
812 148.0 - 160.0,
813 "the grab bias must anchor on the handle's CURRENT line"
814 );
815 }
816 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
817 use std::sync::Arc;
818
819 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
821 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
822 f()
823 }
824
825 fn render_collapsed_handles(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
829 use crate::layout::LayoutEngine;
830 use crate::renderer::HeadlessRenderer;
831 use crate::widgets::PopupHost;
832 use cranpose_ui_graphics::Size;
833
834 let mut composition = Composition::new(MemoryApplier::new());
835 let key = location_key(file!(), line!(), column!());
836 let state = TextFieldState::new("hello world");
837
838 let mut content = {
839 let state = state.clone();
840 move || {
841 let state = state.clone();
842 PopupHost(move || {
843 let controller = TextFieldHandleController::new();
844 controller.publish(TextFieldHandleMetrics {
845 focused: true,
846 direct_manipulation,
847 node_origin: Point { x: 0.0, y: 10.0 },
848 padding_left: 0.0,
849 padding_top: 0.0,
850 scroll_offset: 0.0,
851 line_height: 18.0,
852 glyph_box: (0.0, 18.0),
853 wrap_width: None,
854 press: None,
855 });
856 SelectionHandles(
857 state.clone(),
858 TextStyle::default(),
859 controller,
860 Color(0.0, 0.478, 1.0, 1.0),
861 );
862 });
863 }
864 };
865
866 composition.render(key, &mut content).expect("render");
867 for _ in 0..16 {
868 if !composition.should_render() {
869 break;
870 }
871 composition.reconcile(key, &mut content).expect("reconcile");
872 }
873 let root = composition.root().expect("root");
874 let handle = composition.runtime_handle();
875 let mut applier = composition.applier_mut();
876 applier.set_runtime_handle(handle);
877 let layout = applier
878 .compute_layout(
879 root,
880 Size {
881 width: 400.0,
882 height: 400.0,
883 },
884 )
885 .expect("layout");
886 applier.clear_runtime_handle();
887 drop(applier);
888 HeadlessRenderer::new().render(&layout)
889 }
890
891 fn render_range_menu(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
894 use crate::layout::LayoutEngine;
895 use crate::renderer::HeadlessRenderer;
896 use crate::widgets::PopupHost;
897 use cranpose_ui_graphics::Size;
898
899 let mut composition = Composition::new(MemoryApplier::new());
900 let key = location_key(file!(), line!(), column!());
901 let state = TextFieldState::new("hello world");
902
903 let mut content = {
904 let state = state.clone();
905 move || {
906 let state = state.clone();
907 PopupHost(move || {
908 let controller = TextFieldHandleController::new();
909 if state.selection() != TextRange::new(0, 5) {
910 state.set_selection(TextRange::new(0, 5));
911 }
912 controller.publish(TextFieldHandleMetrics {
913 focused: true,
914 direct_manipulation,
915 node_origin: Point { x: 0.0, y: 40.0 },
916 padding_left: 0.0,
917 padding_top: 0.0,
918 scroll_offset: 0.0,
919 line_height: 18.0,
920 glyph_box: (0.0, 18.0),
921 wrap_width: None,
922 press: None,
923 });
924 SelectionHandles(
925 state.clone(),
926 TextStyle::default(),
927 controller,
928 Color(0.0, 0.478, 1.0, 1.0),
929 );
930 });
931 }
932 };
933
934 composition.render(key, &mut content).expect("render");
935 for _ in 0..16 {
936 if !composition.should_render() {
937 break;
938 }
939 composition.reconcile(key, &mut content).expect("reconcile");
940 }
941 let root = composition.root().expect("root");
942 let handle = composition.runtime_handle();
943 let mut applier = composition.applier_mut();
944 applier.set_runtime_handle(handle);
945 let layout = applier
946 .compute_layout(
947 root,
948 Size {
949 width: 400.0,
950 height: 400.0,
951 },
952 )
953 .expect("layout");
954 applier.clear_runtime_handle();
955 drop(applier);
956 HeadlessRenderer::new().render(&layout)
957 }
958
959 fn render_range_menu_subcomposed(
965 direct_manipulation: bool,
966 ) -> crate::renderer::RecordedRenderScene {
967 use crate::layout::LayoutEngine;
968 use crate::renderer::HeadlessRenderer;
969 use crate::widgets::{BoxWithConstraints, PopupHost};
970 use cranpose_ui_graphics::Size;
971
972 let mut composition = Composition::new(MemoryApplier::new());
973 let key = location_key(file!(), line!(), column!());
974 let state = TextFieldState::new("hello world");
975
976 let mut content = {
977 let state = state.clone();
978 move || {
979 let state = state.clone();
980 PopupHost(move || {
981 let state = state.clone();
982 BoxWithConstraints(
983 Modifier::empty().size(Size {
984 width: 300.0,
985 height: 300.0,
986 }),
987 move |_scope| {
988 let controller = TextFieldHandleController::new();
989 if state.selection() != TextRange::new(0, 5) {
990 state.set_selection(TextRange::new(0, 5));
991 }
992 controller.publish(TextFieldHandleMetrics {
993 focused: true,
994 direct_manipulation,
995 node_origin: Point { x: 0.0, y: 40.0 },
996 padding_left: 0.0,
997 padding_top: 0.0,
998 scroll_offset: 0.0,
999 line_height: 18.0,
1000 glyph_box: (0.0, 18.0),
1001 wrap_width: None,
1002 press: None,
1003 });
1004 SelectionHandles(
1005 state.clone(),
1006 TextStyle::default(),
1007 controller,
1008 Color(0.0, 0.478, 1.0, 1.0),
1009 );
1010 },
1011 );
1012 });
1013 }
1014 };
1015
1016 composition.render(key, &mut content).expect("render");
1017 let root = composition.root().expect("root");
1018 let handle = composition.runtime_handle();
1019 let mut scene = None;
1020 for _ in 0..8 {
1024 for _ in 0..16 {
1025 if !composition.should_render() {
1026 break;
1027 }
1028 composition.reconcile(key, &mut content).expect("reconcile");
1029 }
1030 let mut applier = composition.applier_mut();
1031 applier.set_runtime_handle(handle.clone());
1032 let layout = applier
1033 .compute_layout(
1034 root,
1035 Size {
1036 width: 400.0,
1037 height: 400.0,
1038 },
1039 )
1040 .expect("layout");
1041 applier.clear_runtime_handle();
1042 drop(applier);
1043 scene = Some(HeadlessRenderer::new().render(&layout));
1044 }
1045 scene.expect("scene")
1046 }
1047
1048 fn render_range_menu_lazy_column(
1055 direct_manipulation: bool,
1056 ) -> crate::renderer::RecordedRenderScene {
1057 use crate::layout::LayoutEngine;
1058 use crate::renderer::HeadlessRenderer;
1059 use crate::widgets::PopupHost;
1060 use crate::{LazyColumn, LazyColumnSpec};
1061 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
1062 use cranpose_ui_graphics::Size;
1063
1064 let mut composition = Composition::new(MemoryApplier::new());
1065 let key = location_key(file!(), line!(), column!());
1066 let state = TextFieldState::new("hello world");
1067
1068 let mut content = {
1069 let state = state.clone();
1070 move || {
1071 let state = state.clone();
1072 PopupHost(move || {
1073 let state = state.clone();
1074 let list_state = remember_lazy_list_state();
1075 LazyColumn(
1076 Modifier::empty().size(Size {
1077 width: 300.0,
1078 height: 300.0,
1079 }),
1080 list_state,
1081 LazyColumnSpec::default(),
1082 move |scope| {
1083 let state = state.clone();
1084 scope.items(
1085 1,
1086 None::<fn(usize) -> u64>,
1087 None::<fn(usize) -> u64>,
1088 move |_index| {
1089 let controller = TextFieldHandleController::new();
1090 if state.selection() != TextRange::new(0, 5) {
1091 state.set_selection(TextRange::new(0, 5));
1092 }
1093 controller.publish(TextFieldHandleMetrics {
1094 focused: true,
1095 direct_manipulation,
1096 node_origin: Point { x: 0.0, y: 40.0 },
1097 padding_left: 0.0,
1098 padding_top: 0.0,
1099 scroll_offset: 0.0,
1100 line_height: 18.0,
1101 glyph_box: (0.0, 18.0),
1102 wrap_width: None,
1103 press: None,
1104 });
1105 SelectionHandles(
1106 state.clone(),
1107 TextStyle::default(),
1108 controller,
1109 Color(0.0, 0.478, 1.0, 1.0),
1110 );
1111 },
1112 );
1113 },
1114 );
1115 });
1116 }
1117 };
1118
1119 composition.render(key, &mut content).expect("render");
1120 let root = composition.root().expect("root");
1121 let handle = composition.runtime_handle();
1122 let mut scene = None;
1123 for _ in 0..8 {
1124 for _ in 0..16 {
1125 if !composition.should_render() {
1126 break;
1127 }
1128 composition.reconcile(key, &mut content).expect("reconcile");
1129 }
1130 let mut applier = composition.applier_mut();
1131 applier.set_runtime_handle(handle.clone());
1132 let layout = applier
1133 .compute_layout(
1134 root,
1135 Size {
1136 width: 400.0,
1137 height: 400.0,
1138 },
1139 )
1140 .expect("layout");
1141 applier.clear_runtime_handle();
1142 drop(applier);
1143 scene = Some(HeadlessRenderer::new().render(&layout));
1144 }
1145 scene.expect("scene")
1146 }
1147
1148 #[test]
1157 fn field_window_origin_follows_vertical_scroll() {
1158 use crate::layout::policies::EmptyMeasurePolicy;
1159 use crate::layout::{LayoutBox, LayoutEngine};
1160 use crate::renderer::HeadlessRenderer;
1161 use crate::scroll::ScrollState;
1162 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
1163 use cranpose_core::{remember, Key};
1164 use cranpose_foundation::modifier_element;
1165 use cranpose_ui_graphics::Size;
1166 use std::cell::RefCell;
1167
1168 let _app_context = crate::render_state::app_context_test_scope();
1169
1170 let mut composition = Composition::new(MemoryApplier::new());
1173 let state = TextFieldState::new("hello world");
1174 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
1175 Rc::new(RefCell::new(None));
1176 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1179
1180 let spacer_before = 200.0_f32;
1181 let mut content = {
1182 let state = state.clone();
1183 let controller_slot = Rc::clone(&controller_slot);
1184 let scroll_slot = Rc::clone(&scroll_slot);
1185 move || {
1186 let state = state.clone();
1187 let controller_slot = Rc::clone(&controller_slot);
1188 let scroll_slot = Rc::clone(&scroll_slot);
1189 PopupHost(move || {
1190 let controller = remember(TextFieldHandleController::new)
1191 .with(TextFieldHandleController::clone);
1192 *controller_slot.borrow_mut() = Some(controller.clone());
1193 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
1194 *scroll_slot.borrow_mut() = Some(scroll.clone());
1195 let state = state.clone();
1196 let controller = controller.clone();
1197 Column(
1198 Modifier::empty()
1199 .size(Size {
1200 width: 300.0,
1201 height: 150.0,
1202 })
1203 .vertical_scroll(scroll.clone(), false),
1204 ColumnSpec::default(),
1205 move || {
1206 Spacer(Size {
1207 width: 300.0,
1208 height: spacer_before,
1209 });
1210 let element =
1211 TextFieldElement::new(state.clone(), TextStyle::default())
1212 .with_handle_controller(controller.clone());
1213 let field_modifier =
1214 Modifier::from_parts(vec![modifier_element(element)]);
1215 Layout(field_modifier, EmptyMeasurePolicy, || {});
1216 Spacer(Size {
1217 width: 300.0,
1218 height: 400.0,
1219 });
1220 },
1221 );
1222 });
1223 }
1224 };
1225
1226 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1228 if node
1229 .node_data
1230 .modifier_slices()
1231 .text_field_window_origin()
1232 .is_some()
1233 {
1234 return Some(node.rect);
1235 }
1236 node.children.iter().find_map(find_field_rect)
1237 }
1238
1239 fn layout_and_read(
1240 composition: &mut Composition<MemoryApplier>,
1241 key: Key,
1242 content: &mut dyn FnMut(),
1243 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1244 ) -> (Point, f32) {
1245 for _ in 0..16 {
1246 if !composition.should_render() {
1247 break;
1248 }
1249 composition
1250 .reconcile(key, &mut *content)
1251 .expect("reconcile");
1252 }
1253 let root = composition.root().expect("root");
1254 let handle = composition.runtime_handle();
1255 let mut applier = composition.applier_mut();
1256 applier.set_runtime_handle(handle);
1257 let layout = applier
1258 .compute_layout(
1259 root,
1260 cranpose_ui_graphics::Size {
1261 width: 400.0,
1262 height: 600.0,
1263 },
1264 )
1265 .expect("layout");
1266 applier.clear_runtime_handle();
1267 drop(applier);
1268 let _ = HeadlessRenderer::new().render(&layout);
1269 let field_y = find_field_rect(layout.root()).expect("field placed").y;
1270 let node_origin = controller_slot
1271 .borrow()
1272 .as_ref()
1273 .expect("controller")
1274 .metrics()
1275 .expect("metrics published")
1276 .node_origin;
1277 (node_origin, field_y)
1278 }
1279
1280 let key = location_key(file!(), line!(), column!());
1281 composition.render(key, &mut content).expect("render");
1282
1283 let (origin0, field_y0) =
1284 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1285 assert!(
1288 (origin0.y - field_y0).abs() < 0.5,
1289 "published node_origin.y {} must equal the field's placed window-y {}",
1290 origin0.y,
1291 field_y0
1292 );
1293 assert!(
1294 origin0.y >= spacer_before - 0.5,
1295 "field should start at/after the {spacer_before}px leading spacer, got {}",
1296 origin0.y
1297 );
1298
1299 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
1302 scroll.scroll_to(50.0);
1303 assert!(
1304 scroll.value() >= 49.5,
1305 "test setup: content must be tall enough to scroll 50px (got {})",
1306 scroll.value()
1307 );
1308 let (origin1, field_y1) =
1309 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1310 assert!(
1311 (origin1.y - field_y1).abs() < 0.5,
1312 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1313 origin1.y,
1314 field_y1
1315 );
1316 assert!(
1317 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1318 "scrolling 50px must shift the published field origin up by 50px: \
1319 before {}, after {} (expected {})",
1320 origin0.y,
1321 origin1.y,
1322 origin0.y - 50.0
1323 );
1324 }
1325
1326 #[test]
1332 fn window_offset_roundtrip_holds_under_scroll_offset() {
1333 let _app_context = crate::render_state::app_context_test_scope();
1334 let text = "hello world";
1335 let style = TextStyle::default();
1336 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1338 let metrics = TextFieldHandleMetrics {
1339 focused: true,
1340 direct_manipulation: true,
1341 node_origin,
1342 padding_left: 4.0,
1343 padding_top: 3.0,
1344 scroll_offset: 0.0,
1345 line_height: 18.0,
1346 glyph_box: (0.0, 18.0),
1347 wrap_width: None,
1348 press: None,
1349 };
1350 for offset in 0..=text.len() {
1351 if !text.is_char_boundary(offset) {
1352 continue;
1353 }
1354 let tip =
1357 handle_tip_window_pos(text, &style, &metrics, offset, LineAffinity::Downstream);
1358 let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1359 assert_eq!(
1360 resolved, offset,
1361 "finger at the tip of offset {offset} must map back to it \
1362 under origin {node_origin:?}, got {resolved}"
1363 );
1364 }
1365 }
1366 }
1367
1368 #[test]
1376 fn shared_wrap_boundary_anchors_by_handle_affinity() {
1377 let _app_context = crate::render_state::app_context_test_scope();
1378 with_test_runtime(|| {
1379 let text = "aaaaaaaaaaaaaaaaaaaaaaaa";
1380 let style = TextStyle::default();
1381 let annotated = crate::text::AnnotatedString::from(text);
1382 let full = crate::text::measure_text(&annotated, &style);
1383 let wrap_width = full.width / 3.0;
1385 let ranges = crate::text::wrapped_line_ranges(
1386 None,
1387 &annotated,
1388 &style,
1389 crate::text::TextLayoutOptions::default(),
1390 Some(wrap_width),
1391 );
1392 assert!(
1393 ranges.len() >= 2,
1394 "test setup: text must wrap, got {ranges:?}"
1395 );
1396 let boundary = ranges[1].start;
1397 assert_eq!(
1398 ranges[0].end, boundary,
1399 "test setup: a mid-word wrap must share its boundary byte, got {ranges:?}"
1400 );
1401
1402 let line_height = 20.0;
1403 let metrics = TextFieldHandleMetrics {
1404 focused: true,
1405 direct_manipulation: true,
1406 node_origin: Point { x: 0.0, y: 0.0 },
1407 padding_left: 0.0,
1408 padding_top: 0.0,
1409 scroll_offset: 0.0,
1410 line_height,
1411 glyph_box: (0.0, line_height),
1412 wrap_width: Some(wrap_width),
1413 press: None,
1414 };
1415
1416 let end_tip =
1417 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Upstream);
1418 assert!(
1419 (end_tip.y - line_height).abs() < 0.5,
1420 "end handle must sit on the UPPER line's bottom ({line_height}), got y={}",
1421 end_tip.y
1422 );
1423 assert!(
1424 end_tip.x > 1.0,
1425 "end handle must sit at the upper line's right edge, got x={}",
1426 end_tip.x
1427 );
1428
1429 let start_tip =
1430 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Downstream);
1431 assert!(
1432 (start_tip.y - 2.0 * line_height).abs() < 0.5,
1433 "start handle must sit on the LOWER line's bottom ({}), got y={}",
1434 2.0 * line_height,
1435 start_tip.y
1436 );
1437 assert!(
1438 start_tip.x.abs() < 0.5,
1439 "start handle must sit at the lower line's left edge, got x={}",
1440 start_tip.x
1441 );
1442
1443 let mut grab = HandleGrabOffset::begin(end_tip.y, end_tip.y);
1449 for finger_y in [
1450 end_tip.y,
1451 end_tip.y + 8.0,
1452 end_tip.y + 32.0,
1453 end_tip.y + 80.0,
1454 ] {
1455 let bias = grab.track(finger_y);
1456 let resolved = window_pos_to_offset(
1457 text,
1458 &style,
1459 &metrics,
1460 Point {
1461 x: end_tip.x,
1462 y: finger_y,
1463 },
1464 bias,
1465 );
1466 let resolved_tip =
1467 handle_tip_window_pos(text, &style, &metrics, resolved, LineAffinity::Upstream);
1468 let target_tip_y =
1469 (finger_y + bias).clamp(line_height, ranges.len() as f32 * line_height);
1470 assert!(
1471 (resolved_tip.y - target_tip_y).abs() <= line_height * 0.5 + 0.5,
1472 "finger y={finger_y}, bias={bias} resolved to offset {resolved} at y={}, expected the nearest visual-line bottom to {}",
1473 resolved_tip.y,
1474 target_tip_y,
1475 );
1476 }
1477 })
1478 }
1479
1480 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1481 use crate::renderer::RenderOp;
1482 scene
1483 .operations()
1484 .iter()
1485 .filter_map(|op| match op {
1486 RenderOp::Text { value, .. } => Some(value.clone()),
1487 _ => None,
1488 })
1489 .collect()
1490 }
1491
1492 fn render_caret_action_menu(
1496 can_paste: bool,
1497 can_undo: bool,
1498 can_redo: bool,
1499 ) -> crate::renderer::RecordedRenderScene {
1500 use crate::layout::LayoutEngine;
1501 use crate::renderer::HeadlessRenderer;
1502 use crate::widgets::PopupHost;
1503 use cranpose_ui_graphics::Size;
1504
1505 let mut composition = Composition::new(MemoryApplier::new());
1506 let key = location_key(file!(), line!(), column!());
1507
1508 let mut content = move || {
1509 PopupHost(move || {
1510 CaretActionMenu(
1511 40.0,
1512 60.0,
1513 true,
1514 can_paste,
1515 can_undo,
1516 can_redo,
1517 || {},
1518 || {},
1519 || {},
1520 || {},
1521 );
1522 });
1523 };
1524
1525 composition.render(key, &mut content).expect("render");
1526 for _ in 0..16 {
1527 if !composition.should_render() {
1528 break;
1529 }
1530 composition.reconcile(key, &mut content).expect("reconcile");
1531 }
1532 let root = composition.root().expect("root");
1533 let handle = composition.runtime_handle();
1534 let mut applier = composition.applier_mut();
1535 applier.set_runtime_handle(handle);
1536 let layout = applier
1537 .compute_layout(
1538 root,
1539 Size {
1540 width: 400.0,
1541 height: 400.0,
1542 },
1543 )
1544 .expect("layout");
1545 applier.clear_runtime_handle();
1546 drop(applier);
1547 HeadlessRenderer::new().render(&layout)
1548 }
1549
1550 #[test]
1554 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1555 let _app_context = crate::render_state::app_context_test_scope();
1556
1557 let all = text_values(&render_caret_action_menu(true, true, true));
1558 for label in ["Paste", "Select all", "Undo", "Redo"] {
1559 assert!(
1560 all.iter().any(|t| t == label),
1561 "caret menu should show {label:?}, got {all:?}"
1562 );
1563 }
1564
1565 let bare = text_values(&render_caret_action_menu(false, false, false));
1567 assert!(
1568 bare.iter().any(|t| t == "Select all"),
1569 "Select all is always available, got {bare:?}"
1570 );
1571 assert!(
1572 !bare
1573 .iter()
1574 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1575 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1576 );
1577 }
1578
1579 #[test]
1580 fn context_menu_shows_for_pointer_selection_on_every_platform() {
1581 let _app_context = crate::render_state::app_context_test_scope();
1582
1583 let touch = text_values(&render_range_menu(true));
1584 assert!(
1585 touch.iter().any(|t| t == "Copy"),
1586 "touch selection should show the Copy menu item, got {touch:?}"
1587 );
1588 assert!(
1589 touch.iter().any(|t| t == "Cut"),
1590 "expected Cut, got {touch:?}"
1591 );
1592 assert!(
1593 touch.iter().any(|t| t == "Select all"),
1594 "expected Select all, got {touch:?}"
1595 );
1596
1597 let mouse = text_values(&render_range_menu(true));
1598 assert!(
1599 mouse.iter().any(|t| t == "Copy"),
1600 "mouse selection must expose the same direct-manipulation menu, got {mouse:?}"
1601 );
1602 let keyboard = text_values(&render_range_menu(false));
1603 assert!(
1604 !keyboard.iter().any(|t| t == "Copy"),
1605 "keyboard-only focus must keep a clean caret, got {keyboard:?}"
1606 );
1607 }
1608
1609 #[test]
1610 fn selection_handles_and_menu_survive_subcomposition() {
1611 let _app_context = crate::render_state::app_context_test_scope();
1617 let scene = render_range_menu_subcomposed(true);
1618
1619 let texts = text_values(&scene);
1620 assert!(
1621 texts.iter().any(|t| t == "Copy"),
1622 "a touch selection inside a subcomposition should show the Copy menu \
1623 item through the host, got {texts:?}"
1624 );
1625 assert!(
1626 texts.iter().any(|t| t == "Select all"),
1627 "expected Select all inside a subcomposition, got {texts:?}"
1628 );
1629 assert_eq!(
1630 image_count(&scene),
1631 2,
1632 "a touch range selection should show two finger teardrop handles in \
1633 the overlay across the subcomposition boundary"
1634 );
1635 }
1636
1637 #[test]
1638 fn selection_handles_and_menu_survive_lazy_column_item() {
1639 let _app_context = crate::render_state::app_context_test_scope();
1646 let scene = render_range_menu_lazy_column(true);
1647
1648 let texts = text_values(&scene);
1649 assert!(
1650 texts.iter().any(|t| t == "Copy"),
1651 "a touch selection inside a LazyColumn item should show the Copy menu \
1652 item through the host, got {texts:?}"
1653 );
1654 assert!(
1655 texts.iter().any(|t| t == "Select all"),
1656 "expected Select all inside a LazyColumn item, got {texts:?}"
1657 );
1658 assert_eq!(
1659 image_count(&scene),
1660 2,
1661 "a touch range selection should show two finger teardrop handles in \
1662 the overlay across the LazyColumn item subcomposition boundary"
1663 );
1664 }
1665
1666 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1667 use crate::renderer::RenderOp;
1668 use cranpose_ui_graphics::DrawPrimitive;
1669 scene
1670 .operations()
1671 .iter()
1672 .filter(|op| {
1673 matches!(
1674 op,
1675 RenderOp::Primitive {
1676 primitive: DrawPrimitive::Image { .. },
1677 ..
1678 }
1679 )
1680 })
1681 .count()
1682 }
1683
1684 #[test]
1685 fn cursor_handle_shows_for_pointer_selection_on_every_platform() {
1686 let _app_context = crate::render_state::app_context_test_scope();
1687 assert_eq!(
1688 image_count(&render_collapsed_handles(true)),
1689 1,
1690 "a touch caret should show one finger cursor handle in the overlay"
1691 );
1692 assert_eq!(
1693 image_count(&render_collapsed_handles(true)),
1694 1,
1695 "a mouse-created caret should expose its draggable handle"
1696 );
1697 assert_eq!(
1698 image_count(&render_collapsed_handles(false)),
1699 0,
1700 "keyboard-only focus should keep a clean caret"
1701 );
1702 }
1703
1704 #[test]
1705 fn basic_text_field_creates_node() {
1706 let _app_context = crate::render_state::app_context_test_scope();
1707 let mut composition = Composition::new(MemoryApplier::new());
1708 let state = TextFieldState::new("Test content");
1709
1710 let result = composition.render(location_key(file!(), line!(), column!()), {
1711 let state = state.clone();
1712 move || {
1713 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1714 }
1715 });
1716
1717 assert!(result.is_ok());
1718 assert!(composition.root().is_some());
1719 }
1720
1721 #[test]
1722 fn basic_text_field_state_updates() {
1723 let _app_context = crate::render_state::app_context_test_scope();
1724 with_test_runtime(|| {
1725 let state = TextFieldState::new("Hello");
1726 assert_eq!(state.text(), "Hello");
1727
1728 state.edit(|buffer| {
1729 buffer.place_cursor_at_end();
1730 buffer.insert("!");
1731 });
1732
1733 assert_eq!(state.text(), "Hello!");
1734 });
1735 }
1736
1737 #[test]
1746 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1747 use crate::bring_into_view::local_bring_into_view_responder;
1748 use crate::layout::LayoutEngine;
1749 use crate::renderer::HeadlessRenderer;
1750 use crate::widgets::{Box, BoxSpec, PopupHost};
1751 use crate::{LazyColumn, LazyColumnSpec};
1752 use cranpose_core::Key;
1753 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1754 use cranpose_ui_graphics::Size;
1755 use std::cell::RefCell;
1756
1757 let _app_context = crate::render_state::app_context_test_scope();
1758 let mut composition = Composition::new(MemoryApplier::new());
1759 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1760 Rc::new(RefCell::new(None));
1761 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1762
1763 let mut content = {
1766 let responder_slot = Rc::clone(&responder_slot);
1767 let state_slot = Rc::clone(&state_slot);
1768 move || {
1769 let responder_slot = Rc::clone(&responder_slot);
1770 let state_slot = Rc::clone(&state_slot);
1771 PopupHost(move || {
1772 let list_state = remember_lazy_list_state();
1773 *state_slot.borrow_mut() = Some(list_state);
1774 let responder_slot = Rc::clone(&responder_slot);
1775 LazyColumn(
1776 Modifier::empty().size(Size {
1777 width: 300.0,
1778 height: 400.0,
1779 }),
1780 list_state,
1781 LazyColumnSpec::default(),
1782 move |scope| {
1783 let responder_slot = Rc::clone(&responder_slot);
1784 scope.items(
1785 30,
1786 None::<fn(usize) -> u64>,
1787 None::<fn(usize) -> u64>,
1788 move |_index| {
1789 if responder_slot.borrow().is_none() {
1790 if let Some(r) = local_bring_into_view_responder().current()
1791 {
1792 *responder_slot.borrow_mut() = Some(r);
1793 }
1794 }
1795 Box(
1796 Modifier::empty().size(Size {
1797 width: 300.0,
1798 height: 80.0,
1799 }),
1800 BoxSpec::default(),
1801 || {},
1802 );
1803 },
1804 );
1805 },
1806 );
1807 });
1808 }
1809 };
1810
1811 fn run_layout(
1812 composition: &mut Composition<MemoryApplier>,
1813 key: Key,
1814 content: &mut dyn FnMut(),
1815 ) {
1816 for _ in 0..16 {
1817 if !composition.should_render() {
1818 break;
1819 }
1820 composition
1821 .reconcile(key, &mut *content)
1822 .expect("reconcile");
1823 }
1824 let root = composition.root().expect("root");
1825 let handle = composition.runtime_handle();
1826 let mut applier = composition.applier_mut();
1827 applier.set_runtime_handle(handle);
1828 let layout = applier
1829 .compute_layout(
1830 root,
1831 Size {
1832 width: 400.0,
1833 height: 600.0,
1834 },
1835 )
1836 .expect("layout");
1837 applier.clear_runtime_handle();
1838 drop(applier);
1839 let _ = HeadlessRenderer::new().render(&layout);
1840 }
1841
1842 let key = location_key(file!(), line!(), column!());
1843 composition.render(key, &mut content).expect("render");
1844 run_layout(&mut composition, key, &mut content);
1845
1846 let responder = responder_slot
1847 .borrow()
1848 .clone()
1849 .expect("LazyColumn provides a bring-into-view responder to its items");
1850 let list_state = state_slot.borrow().expect("list state captured");
1851 let offset0 = list_state.first_visible_item_scroll_offset();
1852 let index0 = list_state.first_visible_item_index();
1853
1854 responder.bring_into_view(
1857 Rect {
1858 x: 10.0,
1859 y: 100.0,
1860 width: 2.0,
1861 height: 20.0,
1862 },
1863 0.0,
1864 );
1865 run_layout(&mut composition, key, &mut content);
1866 assert_eq!(
1867 list_state.first_visible_item_index(),
1868 index0,
1869 "an already-visible caret must not scroll the list"
1870 );
1871 assert!(
1872 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1873 "an already-visible caret must not scroll the list"
1874 );
1875
1876 responder.bring_into_view(
1879 Rect {
1880 x: 10.0,
1881 y: 360.0,
1882 width: 2.0,
1883 height: 20.0,
1884 },
1885 250.0,
1886 );
1887 run_layout(&mut composition, key, &mut content);
1888 let scrolled_forward = list_state.first_visible_item_index() > index0
1889 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1890 assert!(
1891 scrolled_forward,
1892 "a caret behind the keyboard must scroll the list forward \
1893 (index {} -> {}, offset {:.1} -> {:.1})",
1894 index0,
1895 list_state.first_visible_item_index(),
1896 offset0,
1897 list_state.first_visible_item_scroll_offset(),
1898 );
1899 }
1900}