1#![allow(non_snake_case)]
7
8use std::{
9 cell::{Cell, RefCell},
10 rc::{Rc, Weak},
11};
12
13use cranpose_core::{MutableState, NodeId, SideEffect, mutableStateOf, remember};
14use cranpose_foundation::{
15 modifier_element,
16 text::{TextFieldLineLimits, TextFieldState, TextRange},
17};
18use cranpose_ui_graphics::{Color, Point, Rect};
19
20use crate::{
21 bring_into_view::local_bring_into_view_responder,
22 clipboard_session::{clipboard_can_paste, clipboard_paste_into_focus, clipboard_write_text},
23 composable,
24 layout::policies::EmptyMeasurePolicy,
25 modifier::Modifier,
26 safe_area::local_ime_insets,
27 text::{AnnotatedString, TextStyle, measure_text},
28 text_field_focus::{dispatch_copy, dispatch_cut, dispatch_select_all},
29 text_field_modifier_node::{
30 TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
31 },
32 text_selection::{
33 HANDLE_RADIUS, HandleGrabOffset, HandleKind, LineAffinity, selection_after_handle_drag,
34 },
35 widgets::{
36 CaretActionMenu, Layout, SelectionHandle, SelectionLoupe, TextSelectionMenu,
37 loupe_target_for_drag,
38 },
39};
40
41pub const SELECTION_HIGHLIGHT_ALPHA: f32 = 0.32;
46
47const TEXT_LONG_PRESS_MS: u64 = 500;
50const TEXT_LONG_PRESS_SLOP: f32 = 12.0;
53
54struct LongPressWatcher {
61 controller: TextFieldHandleController,
62 state: TextFieldState,
63 style: TextStyle,
64 start: Point,
65 start_nanos: Cell<Option<u64>>,
66 registration: RefCell<Option<cranpose_core::internal::FrameCallbackRegistration>>,
67 frame_clock: cranpose_core::internal::FrameClock,
68}
69
70impl LongPressWatcher {
71 fn arm(self: &Rc<Self>) {
72 let weak: Weak<LongPressWatcher> = Rc::downgrade(self);
73 let registration = self.frame_clock.with_frame_nanos(move |now| {
74 let Some(watcher) = weak.upgrade() else {
75 return;
76 };
77 watcher.tick(now);
78 });
79 *self.registration.borrow_mut() = Some(registration);
80 crate::request_render_invalidation();
83 }
84
85 fn tick(self: Rc<Self>, now: u64) {
86 self.registration.borrow_mut().take();
87 let Some(press) = self.controller.press() else {
88 return; };
90 let moved = (press.position.x - self.start.x)
91 .abs()
92 .max((press.position.y - self.start.y).abs());
93 if (press.start.x - self.start.x).abs() > 0.5
94 || (press.start.y - self.start.y).abs() > 0.5
95 || moved > TEXT_LONG_PRESS_SLOP
96 {
97 return; }
99 let start = match self.start_nanos.get() {
100 Some(value) => value,
101 None => {
102 self.start_nanos.set(Some(now));
103 now
104 }
105 };
106 if now.saturating_sub(start) < TEXT_LONG_PRESS_MS * 1_000_000 {
107 self.arm();
108 return;
109 }
110 self.controller.claim_gesture();
114 let Some(metrics) = self.controller.metrics() else {
115 return;
116 };
117 let text = self.state.text();
118 let offset = window_pos_to_offset(&text, &self.style, &metrics, self.start, 0.0);
119 let (word_start, word_end) = crate::word_boundaries::find_word_boundaries(&text, offset);
120 self.state.edit(|buffer| {
121 buffer.select(TextRange::new(word_start, word_end));
122 });
123 crate::request_render_invalidation();
124 }
125}
126
127fn handle_tip_window_pos(
133 text: &str,
134 style: &TextStyle,
135 metrics: &TextFieldHandleMetrics,
136 offset: usize,
137 affinity: LineAffinity,
138) -> Point {
139 let offset = offset.min(text.len());
140 let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
144 text,
145 style,
146 None,
147 metrics.wrap_width,
148 offset,
149 affinity,
150 );
151 let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
152 Point {
153 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
154 y: metrics.node_origin.y
157 + metrics.padding_top
158 + line_index as f32 * metrics.line_height
159 + metrics.glyph_box.0
160 + metrics.glyph_box.1,
161 }
162}
163
164fn window_pos_to_offset(
171 text: &str,
172 style: &TextStyle,
173 metrics: &TextFieldHandleMetrics,
174 window_pos: Point,
175 y_bias: f32,
176) -> usize {
177 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
178 + metrics.scroll_offset)
179 .max(0.0);
180 let local_y = (window_pos.y + y_bias
183 - 0.5 * metrics.line_height
184 - metrics.node_origin.y
185 - metrics.padding_top)
186 .max(0.0);
187 crate::text::offset_for_position_wrapped(
192 text,
193 style,
194 None,
195 metrics.wrap_width,
196 metrics.line_height,
197 local_x,
198 local_y,
199 )
200}
201#[composable]
219pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
220 BasicTextFieldWithOptions(
221 state,
222 modifier,
223 BasicTextFieldOptions {
224 text_style: style,
225 ..BasicTextFieldOptions::default()
226 },
227 )
228}
229
230#[derive(Debug, Clone, PartialEq)]
232pub struct BasicTextFieldOptions {
233 pub text_style: TextStyle,
235 pub cursor_color: Color,
237 pub line_limits: TextFieldLineLimits,
239}
240
241impl Default for BasicTextFieldOptions {
242 fn default() -> Self {
243 Self {
244 text_style: TextStyle::default(),
245 cursor_color: Color(0.0, 0.478, 1.0, 1.0),
248 line_limits: TextFieldLineLimits::default(),
249 }
250 }
251}
252
253#[derive(Clone)]
258pub struct BasicTextFieldDecorationScope {
259 inner: Rc<dyn Fn() -> NodeId>,
260}
261
262impl BasicTextFieldDecorationScope {
263 pub fn inner_text_field(&self) -> NodeId {
264 (self.inner)()
265 }
266}
267
268impl PartialEq for BasicTextFieldDecorationScope {
269 fn eq(&self, other: &Self) -> bool {
270 Rc::ptr_eq(&self.inner, &other.inner)
271 }
272}
273
274#[composable(no_skip)]
277pub fn BasicTextFieldDecorated<D>(
278 state: TextFieldState,
279 modifier: Modifier,
280 options: BasicTextFieldOptions,
281 decoration_box: D,
282) -> NodeId
283where
284 D: Fn(BasicTextFieldDecorationScope) -> NodeId + 'static,
285{
286 let inner_state = state;
287 let inner_modifier = modifier;
288 let inner_options = options;
289 let scope = BasicTextFieldDecorationScope {
290 inner: Rc::new(move || {
291 BasicTextFieldWithOptions(inner_state, inner_modifier.clone(), inner_options.clone())
292 }),
293 };
294 decoration_box(scope)
295}
296
297#[composable]
301pub fn BasicTextFieldWithOptions(
302 state: TextFieldState,
303 modifier: Modifier,
304 options: BasicTextFieldOptions,
305) -> NodeId {
306 let _text = state.text();
309 let _selection = state.selection();
310
311 let controller =
315 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
316
317 let modal_depth = crate::modal::local_modal_depth().current();
322 let text_field_element = TextFieldElement::new(state, options.text_style.clone())
323 .with_cursor_color(options.cursor_color)
324 .with_line_limits(options.line_limits)
325 .with_handle_controller(controller.clone())
326 .with_modal_depth(modal_depth);
327
328 let text_field_modifier = modifier_element(text_field_element);
330 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
331 let combined_modifier = modifier.then(final_modifier);
332
333 let node = Layout(combined_modifier, EmptyMeasurePolicy, || {});
334
335 BringCaretIntoView(state, options.text_style.clone(), controller.clone());
340
341 SelectionHandles(state, options.text_style, controller, options.cursor_color);
348
349 node
350}
351
352#[cfg(test)]
353mod options_tests {
354 use super::*;
355
356 #[test]
357 fn default_options_use_default_line_limits() {
358 let options = BasicTextFieldOptions::default();
359 assert_eq!(options.line_limits, TextFieldLineLimits::default());
360 }
361
362 #[test]
363 fn decoration_scope_invokes_the_inner_field() {
364 let scope = BasicTextFieldDecorationScope {
365 inner: Rc::new(|| 73),
366 };
367 assert_eq!(scope.inner_text_field(), 73);
368 }
369}
370
371fn caret_window_rect(
375 text: &str,
376 style: &TextStyle,
377 metrics: &TextFieldHandleMetrics,
378 offset: usize,
379) -> Rect {
380 let tip = handle_tip_window_pos(text, style, metrics, offset, LineAffinity::Upstream);
383 Rect {
384 x: tip.x,
385 y: tip.y - metrics.glyph_box.1,
386 width: 2.0,
387 height: metrics.glyph_box.1,
388 }
389}
390
391#[composable]
401fn BringCaretIntoView(
402 state: TextFieldState,
403 style: TextStyle,
404 controller: TextFieldHandleController,
405) {
406 let Some(metrics) = controller.metrics() else {
407 return;
408 };
409 let ime_bottom = local_ime_insets().current().bottom;
412 let responder = local_bring_into_view_responder().current();
413
414 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
415 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
416
417 if !metrics.focused {
418 previous.set(None);
421 return;
422 }
423 let Some(responder) = responder else {
424 return;
425 };
426
427 let text = state.text();
428 let selection = state.selection();
429 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
430
431 let key = (
435 selection.start,
436 selection.end,
437 (ime_bottom * 4.0).round() as i64,
438 );
439 SideEffect(move || {
440 if previous.get() != Some(key) {
441 previous.set(Some(key));
442 responder.bring_into_view(caret, ime_bottom);
443 }
444 });
445}
446
447#[composable]
452fn SelectionHandles(
453 state: TextFieldState,
454 style: TextStyle,
455 controller: TextFieldHandleController,
456 accent: Color,
457) {
458 let selection = state.selection();
459 let current_range = (selection.min(), selection.max());
460 let active_press = controller.press();
461
462 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
466 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
471 let caret_menu_offset: Rc<Cell<usize>> =
474 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
475 let previous_range: Rc<Cell<(usize, usize)>> =
476 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
477 {
478 let previous_range = Rc::clone(&previous_range);
479 SideEffect(move || {
480 if previous_range.get() != current_range {
481 previous_range.set(current_range);
482 menu_open.set(true);
483 }
484 });
485 }
486 {
489 let caret_menu_offset = Rc::clone(&caret_menu_offset);
490 let caret_start = selection.start;
491 SideEffect(move || {
492 if caret_menu_open.value()
493 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
494 {
495 caret_menu_open.set(false);
496 }
497 });
498 }
499
500 let Some(metrics) = controller.metrics() else {
501 return;
502 };
503 if !metrics.focused || !metrics.direct_manipulation {
504 return;
505 }
506
507 let text = state.text();
508
509 let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
515 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
516 let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
517 remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
518 match active_press {
519 Some(press) => {
520 let key = (press.start.x.to_bits(), press.start.y.to_bits());
521 if press_watcher.get() != Some(key) {
522 press_watcher.set(Some(key));
523 let watcher = Rc::new(LongPressWatcher {
524 controller: controller.clone(),
525 state,
526 style: style.clone(),
527 start: press.start,
528 start_nanos: Cell::new(None),
529 registration: RefCell::new(None),
530 frame_clock: cranpose_core::with_current_composer(|composer| {
531 composer.runtime_handle()
532 })
533 .frame_clock(),
534 });
535 watcher.arm();
536 *press_watcher_ref.borrow_mut() = Some(watcher);
537 }
538 }
539 None => {
540 press_watcher.set(None);
541 press_watcher_ref.borrow_mut().take();
542 }
543 }
544
545 let drag_pos: MutableState<Option<Point>> =
549 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
550 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
553 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
554 let last_dragged: Rc<Cell<Option<HandleKind>>> =
559 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
560 let menu_anchor_range: Rc<Cell<(usize, usize)>> =
561 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
562 {
563 let last_dragged = Rc::clone(&last_dragged);
564 let menu_anchor_range = Rc::clone(&menu_anchor_range);
565 SideEffect(move || {
566 if menu_anchor_range.get() != current_range {
567 menu_anchor_range.set(current_range);
568 if drag_pos.value().is_none() {
569 last_dragged.set(None);
570 }
571 }
572 });
573 }
574 let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
582 let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
583 let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
584
585 if selection.collapsed() {
586 let tip = handle_tip_window_pos(
588 &text,
589 &style,
590 &metrics,
591 selection.start,
592 LineAffinity::Upstream,
593 );
594 let on_drag = drag_caret_closure(
595 state,
596 style.clone(),
597 controller.clone(),
598 Rc::clone(&drag_bias),
599 );
600 let open_caret_menu = {
603 let caret_menu_offset = Rc::clone(&caret_menu_offset);
604 move || {
605 caret_menu_offset.set(state.selection().start);
606 caret_menu_open.set(true);
607 }
608 };
609 let on_tap = open_caret_menu.clone();
610 let on_long_press = open_caret_menu;
611 let grab_bias = Rc::clone(&drag_bias);
612 let end_bias = Rc::clone(&drag_bias);
613 cursor_tip_y.set(tip.y);
614 let tip_y = Rc::clone(&cursor_tip_y);
615 SelectionHandle(
616 HandleKind::Cursor,
617 tip,
618 metrics.glyph_box.1,
619 HANDLE_RADIUS,
620 accent,
621 move |pos| {
622 track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
623 drag_pos.set(Some(pos));
624 on_drag(pos);
625 },
626 move || {
627 drag_pos.set(None);
628 end_bias.set(None);
629 crate::cursor_animation::reset_cursor_blink();
632 },
633 on_long_press,
634 on_tap,
635 );
636
637 if caret_menu_open.value() {
642 let can_paste = clipboard_can_paste();
643 let can_undo = state.can_undo();
644 let can_redo = state.can_redo();
645 let undo_state = state;
646 let redo_state = state;
647 CaretActionMenu(
648 tip.x,
649 tip.y - metrics.glyph_box.1,
650 drag_pos.value().is_none(),
651 can_paste,
652 can_undo,
653 can_redo,
654 move || {
655 clipboard_paste_into_focus();
656 caret_menu_open.set(false);
657 },
658 move || {
659 dispatch_select_all();
660 caret_menu_open.set(false);
661 },
662 move || {
663 undo_state.undo();
664 crate::request_render_invalidation();
665 caret_menu_open.set(false);
666 },
667 move || {
668 redo_state.redo();
669 crate::request_render_invalidation();
670 caret_menu_open.set(false);
671 },
672 );
673 }
674 } else {
675 let start = selection.min();
677 let end = selection.max();
678 let start_tip =
679 handle_tip_window_pos(&text, &style, &metrics, start, LineAffinity::Downstream);
680 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end, LineAffinity::Upstream);
681
682 let last_dragged_start = Rc::clone(&last_dragged);
683 let last_dragged_end = Rc::clone(&last_dragged);
684 let on_drag_start = drag_edge_closure(
685 HandleKind::SelectionStart,
686 state,
687 style.clone(),
688 controller.clone(),
689 Rc::clone(&drag_bias),
690 );
691 let grab_bias = Rc::clone(&drag_bias);
692 let end_bias = Rc::clone(&drag_bias);
693 start_tip_y.set(start_tip.y);
694 let start_tip_live = Rc::clone(&start_tip_y);
695 SelectionHandle(
696 HandleKind::SelectionStart,
697 start_tip,
698 metrics.glyph_box.1,
699 HANDLE_RADIUS,
700 accent,
701 move |pos| {
702 track_handle_grab(
703 &grab_bias,
704 HandleKind::SelectionStart,
705 start_tip_live.get(),
706 pos.y,
707 );
708 last_dragged_start.set(Some(HandleKind::SelectionStart));
709 drag_pos.set(Some(pos));
710 on_drag_start(pos);
711 },
712 move || {
713 drag_pos.set(None);
714 end_bias.set(None);
715 },
716 move || menu_open.set(true),
720 move || menu_open.set(true),
722 );
723
724 let on_drag_end = drag_edge_closure(
725 HandleKind::SelectionEnd,
726 state,
727 style.clone(),
728 controller.clone(),
729 Rc::clone(&drag_bias),
730 );
731 let grab_bias = Rc::clone(&drag_bias);
732 let end_bias = Rc::clone(&drag_bias);
733 end_tip_y.set(end_tip.y);
734 let end_tip_live = Rc::clone(&end_tip_y);
735 SelectionHandle(
736 HandleKind::SelectionEnd,
737 end_tip,
738 metrics.glyph_box.1,
739 HANDLE_RADIUS,
740 accent,
741 move |pos| {
742 track_handle_grab(
743 &grab_bias,
744 HandleKind::SelectionEnd,
745 end_tip_live.get(),
746 pos.y,
747 );
748 last_dragged_end.set(Some(HandleKind::SelectionEnd));
749 drag_pos.set(Some(pos));
750 on_drag_end(pos);
751 },
752 move || {
753 drag_pos.set(None);
754 end_bias.set(None);
755 },
756 move || menu_open.set(true),
757 move || menu_open.set(true),
758 );
759
760 if menu_open.value() {
766 let can_paste = clipboard_can_paste();
767 let slide_point = if controller.gesture_claimed() {
770 active_press.map(|press| press.position)
771 } else {
772 None
773 };
774 let (menu_x, menu_top) = match last_dragged.get() {
778 Some(HandleKind::SelectionStart) => {
779 (start_tip.x, start_tip.y - metrics.glyph_box.1)
780 }
781 Some(HandleKind::SelectionEnd) | Some(HandleKind::Cursor) => {
782 (end_tip.x, end_tip.y - metrics.glyph_box.1)
783 }
784 None => (
785 (start_tip.x + end_tip.x) * 0.5,
786 start_tip.y - metrics.glyph_box.1,
787 ),
788 };
789 TextSelectionMenu(
790 menu_x,
791 menu_top,
792 drag_pos.value().is_none(),
793 slide_point,
794 can_paste,
795 move || {
796 if let Some(text) = dispatch_copy() {
797 clipboard_write_text(&text);
798 }
799 menu_open.set(false);
800 },
801 move || {
802 if let Some(text) = dispatch_cut() {
803 clipboard_write_text(&text);
804 }
805 menu_open.set(false);
806 },
807 move || {
808 clipboard_paste_into_focus();
809 menu_open.set(false);
810 },
811 move || {
812 dispatch_select_all();
813 menu_open.set(false);
814 },
815 );
816 }
817 }
818
819 let loupe_target = drag_pos.value().and_then(|finger| {
826 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
827 let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
828 let line_bottom =
831 handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
832 loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
833 });
834 SelectionLoupe(loupe_target);
835}
836
837fn track_handle_grab(
838 drag_bias: &Cell<Option<HandleGrabOffset>>,
839 kind: HandleKind,
840 handle_tip_y: f32,
841 finger_y: f32,
842) -> f32 {
843 let drifts = kind != HandleKind::SelectionStart;
846 let mut grab = drag_bias
847 .get()
848 .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
849 let bias = grab.track(finger_y);
850 drag_bias.set(Some(grab));
851 bias
852}
853
854fn drag_caret_closure(
858 state: TextFieldState,
859 style: TextStyle,
860 controller: TextFieldHandleController,
861 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
862) -> Rc<dyn Fn(Point)> {
863 Rc::new(move |window_pos: Point| {
864 let Some(metrics) = controller.metrics() else {
865 return;
866 };
867 let text = state.text();
868 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
869 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
870 state.set_selection(TextRange::new(offset, offset));
871 crate::cursor_animation::suspend_cursor_blink();
875 crate::request_render_invalidation();
876 })
877}
878
879fn drag_edge_closure(
883 dragged: HandleKind,
884 state: TextFieldState,
885 style: TextStyle,
886 controller: TextFieldHandleController,
887 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
888) -> Rc<dyn Fn(Point)> {
889 Rc::new(move |window_pos: Point| {
890 let Some(metrics) = controller.metrics() else {
891 return;
892 };
893 let text = state.text();
894 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
895 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
896 let selection = state.selection();
897 let fixed_edge = match dragged {
898 HandleKind::SelectionStart => selection.max(),
899 _ => selection.min(),
900 };
901 let (min, max) =
902 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
903 state.set_selection(TextRange::new(min, max));
904 crate::request_render_invalidation();
905 })
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911
912 #[test]
913 fn handle_grab_bias_reads_the_live_tip_not_a_snapshot() {
914 let tip_y: Rc<Cell<f32>> = Rc::new(Cell::new(100.0));
920 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> = Rc::new(Cell::new(None));
921 let grab = {
922 let tip_y = Rc::clone(&tip_y);
923 let drag_bias = Rc::clone(&drag_bias);
924 move |finger_y: f32| {
925 track_handle_grab(&drag_bias, HandleKind::SelectionEnd, tip_y.get(), finger_y)
926 }
927 };
928
929 tip_y.set(148.0);
932 let bias = grab(160.0);
933 assert_eq!(
934 bias,
935 148.0 - 160.0,
936 "the grab bias must anchor on the handle's CURRENT line"
937 );
938 }
939 use std::sync::Arc;
940
941 use cranpose_core::{Composition, DefaultScheduler, MemoryApplier, Runtime, location_key};
942
943 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
945 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
946 f()
947 }
948
949 fn render_collapsed_handles(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
953 use cranpose_ui_graphics::Size;
954
955 use crate::{layout::LayoutEngine, renderer::HeadlessRenderer, widgets::PopupHost};
956
957 let mut composition = Composition::new(MemoryApplier::new());
958 let key = location_key(file!(), line!(), column!());
959 let state = TextFieldState::new("hello world");
960
961 let mut content = move || {
962 PopupHost(move || {
963 let controller = TextFieldHandleController::new();
964 controller.publish(TextFieldHandleMetrics {
965 focused: true,
966 direct_manipulation,
967 node_origin: Point { x: 0.0, y: 10.0 },
968 padding_left: 0.0,
969 padding_top: 0.0,
970 scroll_offset: 0.0,
971 line_height: 18.0,
972 glyph_box: (0.0, 18.0),
973 wrap_width: None,
974 });
975 SelectionHandles(
976 state,
977 TextStyle::default(),
978 controller,
979 Color(0.0, 0.478, 1.0, 1.0),
980 );
981 });
982 };
983
984 composition.render(key, &mut content).expect("render");
985 for _ in 0..16 {
986 if !composition.should_render() {
987 break;
988 }
989 composition.reconcile(key, &mut content).expect("reconcile");
990 }
991 let root = composition.root().expect("root");
992 let handle = composition.runtime_handle();
993 let mut applier = composition.applier_mut();
994 applier.set_runtime_handle(handle);
995 let layout = applier
996 .compute_layout(
997 root,
998 Size {
999 width: 400.0,
1000 height: 400.0,
1001 },
1002 )
1003 .expect("layout");
1004 applier.clear_runtime_handle();
1005 drop(applier);
1006 HeadlessRenderer::new().render(&layout)
1007 }
1008
1009 fn render_range_menu(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
1012 use cranpose_ui_graphics::Size;
1013
1014 use crate::{layout::LayoutEngine, renderer::HeadlessRenderer, widgets::PopupHost};
1015
1016 let mut composition = Composition::new(MemoryApplier::new());
1017 let key = location_key(file!(), line!(), column!());
1018 let state = TextFieldState::new("hello world");
1019
1020 let mut content = move || {
1021 PopupHost(move || {
1022 let controller = TextFieldHandleController::new();
1023 if state.selection() != TextRange::new(0, 5) {
1024 state.set_selection(TextRange::new(0, 5));
1025 }
1026 controller.publish(TextFieldHandleMetrics {
1027 focused: true,
1028 direct_manipulation,
1029 node_origin: Point { x: 0.0, y: 40.0 },
1030 padding_left: 0.0,
1031 padding_top: 0.0,
1032 scroll_offset: 0.0,
1033 line_height: 18.0,
1034 glyph_box: (0.0, 18.0),
1035 wrap_width: None,
1036 });
1037 SelectionHandles(
1038 state,
1039 TextStyle::default(),
1040 controller,
1041 Color(0.0, 0.478, 1.0, 1.0),
1042 );
1043 });
1044 };
1045
1046 composition.render(key, &mut content).expect("render");
1047 for _ in 0..16 {
1048 if !composition.should_render() {
1049 break;
1050 }
1051 composition.reconcile(key, &mut content).expect("reconcile");
1052 }
1053 let root = composition.root().expect("root");
1054 let handle = composition.runtime_handle();
1055 let mut applier = composition.applier_mut();
1056 applier.set_runtime_handle(handle);
1057 let layout = applier
1058 .compute_layout(
1059 root,
1060 Size {
1061 width: 400.0,
1062 height: 400.0,
1063 },
1064 )
1065 .expect("layout");
1066 applier.clear_runtime_handle();
1067 drop(applier);
1068 HeadlessRenderer::new().render(&layout)
1069 }
1070
1071 fn render_range_menu_subcomposed(
1077 direct_manipulation: bool,
1078 ) -> crate::renderer::RecordedRenderScene {
1079 use cranpose_ui_graphics::Size;
1080
1081 use crate::{
1082 layout::LayoutEngine,
1083 renderer::HeadlessRenderer,
1084 widgets::{BoxWithConstraints, PopupHost},
1085 };
1086
1087 let mut composition = Composition::new(MemoryApplier::new());
1088 let key = location_key(file!(), line!(), column!());
1089 let state = TextFieldState::new("hello world");
1090
1091 let mut content = move || {
1092 PopupHost(move || {
1093 let state = state;
1094 BoxWithConstraints(
1095 Modifier::empty().size(Size {
1096 width: 300.0,
1097 height: 300.0,
1098 }),
1099 move |_scope| {
1100 let controller = TextFieldHandleController::new();
1101 if state.selection() != TextRange::new(0, 5) {
1102 state.set_selection(TextRange::new(0, 5));
1103 }
1104 controller.publish(TextFieldHandleMetrics {
1105 focused: true,
1106 direct_manipulation,
1107 node_origin: Point { x: 0.0, y: 40.0 },
1108 padding_left: 0.0,
1109 padding_top: 0.0,
1110 scroll_offset: 0.0,
1111 line_height: 18.0,
1112 glyph_box: (0.0, 18.0),
1113 wrap_width: None,
1114 });
1115 SelectionHandles(
1116 state,
1117 TextStyle::default(),
1118 controller,
1119 Color(0.0, 0.478, 1.0, 1.0),
1120 );
1121 },
1122 );
1123 });
1124 };
1125
1126 composition.render(key, &mut content).expect("render");
1127 let root = composition.root().expect("root");
1128 let handle = composition.runtime_handle();
1129 let mut scene = None;
1130 for _ in 0..8 {
1134 for _ in 0..16 {
1135 if !composition.should_render() {
1136 break;
1137 }
1138 composition.reconcile(key, &mut content).expect("reconcile");
1139 }
1140 let mut applier = composition.applier_mut();
1141 applier.set_runtime_handle(handle.clone());
1142 let layout = applier
1143 .compute_layout(
1144 root,
1145 Size {
1146 width: 400.0,
1147 height: 400.0,
1148 },
1149 )
1150 .expect("layout");
1151 applier.clear_runtime_handle();
1152 drop(applier);
1153 scene = Some(HeadlessRenderer::new().render(&layout));
1154 }
1155 scene.expect("scene")
1156 }
1157
1158 fn render_range_menu_lazy_column(
1165 direct_manipulation: bool,
1166 ) -> crate::renderer::RecordedRenderScene {
1167 use cranpose_foundation::lazy::{LazyListScope, rememberLazyListState};
1168 use cranpose_ui_graphics::Size;
1169
1170 use crate::{
1171 LazyColumn, LazyColumnSpec, layout::LayoutEngine, renderer::HeadlessRenderer,
1172 widgets::PopupHost,
1173 };
1174
1175 let mut composition = Composition::new(MemoryApplier::new());
1176 let key = location_key(file!(), line!(), column!());
1177 let state = TextFieldState::new("hello world");
1178
1179 let mut content = move || {
1180 PopupHost(move || {
1181 let state = state;
1182 let list_state = rememberLazyListState();
1183 LazyColumn(
1184 Modifier::empty().size(Size {
1185 width: 300.0,
1186 height: 300.0,
1187 }),
1188 list_state,
1189 LazyColumnSpec::default(),
1190 move |scope| {
1191 let state = state;
1192 scope.items(1, move |_index| {
1193 let controller = TextFieldHandleController::new();
1194 if state.selection() != TextRange::new(0, 5) {
1195 state.set_selection(TextRange::new(0, 5));
1196 }
1197 controller.publish(TextFieldHandleMetrics {
1198 focused: true,
1199 direct_manipulation,
1200 node_origin: Point { x: 0.0, y: 40.0 },
1201 padding_left: 0.0,
1202 padding_top: 0.0,
1203 scroll_offset: 0.0,
1204 line_height: 18.0,
1205 glyph_box: (0.0, 18.0),
1206 wrap_width: None,
1207 });
1208 SelectionHandles(
1209 state,
1210 TextStyle::default(),
1211 controller,
1212 Color(0.0, 0.478, 1.0, 1.0),
1213 );
1214 });
1215 },
1216 );
1217 });
1218 };
1219
1220 composition.render(key, &mut content).expect("render");
1221 let root = composition.root().expect("root");
1222 let handle = composition.runtime_handle();
1223 let mut scene = None;
1224 for _ in 0..8 {
1225 for _ in 0..16 {
1226 if !composition.should_render() {
1227 break;
1228 }
1229 composition.reconcile(key, &mut content).expect("reconcile");
1230 }
1231 let mut applier = composition.applier_mut();
1232 applier.set_runtime_handle(handle.clone());
1233 let layout = applier
1234 .compute_layout(
1235 root,
1236 Size {
1237 width: 400.0,
1238 height: 400.0,
1239 },
1240 )
1241 .expect("layout");
1242 applier.clear_runtime_handle();
1243 drop(applier);
1244 scene = Some(HeadlessRenderer::new().render(&layout));
1245 }
1246 scene.expect("scene")
1247 }
1248
1249 #[test]
1258 fn field_window_origin_follows_vertical_scroll() {
1259 use std::cell::RefCell;
1260
1261 use cranpose_core::{Key, remember};
1262 use cranpose_foundation::modifier_element;
1263 use cranpose_ui_graphics::Size;
1264
1265 use crate::{
1266 layout::{LayoutBox, LayoutEngine, policies::EmptyMeasurePolicy},
1267 renderer::HeadlessRenderer,
1268 scroll::ScrollState,
1269 widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer},
1270 };
1271
1272 let _app_context = crate::render_state::app_context_test_scope();
1273
1274 let mut composition = Composition::new(MemoryApplier::new());
1277 let state = TextFieldState::new("hello world");
1278 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
1279 Rc::new(RefCell::new(None));
1280 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1283
1284 let spacer_before = 200.0_f32;
1285 let mut content = {
1286 let controller_slot = Rc::clone(&controller_slot);
1287 let scroll_slot = Rc::clone(&scroll_slot);
1288 move || {
1289 let controller_slot = Rc::clone(&controller_slot);
1290 let scroll_slot = Rc::clone(&scroll_slot);
1291 PopupHost(move || {
1292 let controller = remember(TextFieldHandleController::new)
1293 .with(TextFieldHandleController::clone);
1294 *controller_slot.borrow_mut() = Some(controller.clone());
1295 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
1296 *scroll_slot.borrow_mut() = Some(scroll);
1297 let state = state;
1298 let controller = controller.clone();
1299 Column(
1300 Modifier::empty()
1301 .size(Size {
1302 width: 300.0,
1303 height: 150.0,
1304 })
1305 .vertical_scroll(scroll, false),
1306 ColumnSpec::default(),
1307 move || {
1308 Spacer(Size {
1309 width: 300.0,
1310 height: spacer_before,
1311 });
1312 let element = TextFieldElement::new(state, TextStyle::default())
1313 .with_handle_controller(controller.clone());
1314 let field_modifier =
1315 Modifier::from_parts(vec![modifier_element(element)]);
1316 Layout(field_modifier, EmptyMeasurePolicy, || {});
1317 Spacer(Size {
1318 width: 300.0,
1319 height: 400.0,
1320 });
1321 },
1322 );
1323 });
1324 }
1325 };
1326
1327 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1329 if node
1330 .node_data
1331 .modifier_slices()
1332 .text_field_window_origin()
1333 .is_some()
1334 {
1335 return Some(node.rect);
1336 }
1337 node.children.iter().find_map(find_field_rect)
1338 }
1339
1340 fn layout_and_read(
1341 composition: &mut Composition<MemoryApplier>,
1342 key: Key,
1343 content: &mut dyn FnMut(),
1344 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1345 ) -> (Point, f32) {
1346 for _ in 0..16 {
1347 if !composition.should_render() {
1348 break;
1349 }
1350 composition
1351 .reconcile(key, &mut *content)
1352 .expect("reconcile");
1353 }
1354 let root = composition.root().expect("root");
1355 let handle = composition.runtime_handle();
1356 let mut applier = composition.applier_mut();
1357 applier.set_runtime_handle(handle);
1358 let layout = applier
1359 .compute_layout(
1360 root,
1361 cranpose_ui_graphics::Size {
1362 width: 400.0,
1363 height: 600.0,
1364 },
1365 )
1366 .expect("layout");
1367 applier.clear_runtime_handle();
1368 drop(applier);
1369 let _ = HeadlessRenderer::new().render(&layout);
1370 let field_y = find_field_rect(layout.root()).expect("field placed").y;
1371 let node_origin = controller_slot
1372 .borrow()
1373 .as_ref()
1374 .expect("controller")
1375 .metrics()
1376 .expect("metrics published")
1377 .node_origin;
1378 (node_origin, field_y)
1379 }
1380
1381 let key = location_key(file!(), line!(), column!());
1382 composition.render(key, &mut content).expect("render");
1383
1384 let (origin0, field_y0) =
1385 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1386 assert!(
1389 (origin0.y - field_y0).abs() < 0.5,
1390 "published node_origin.y {} must equal the field's placed window-y {}",
1391 origin0.y,
1392 field_y0
1393 );
1394 assert!(
1395 origin0.y >= spacer_before - 0.5,
1396 "field should start at/after the {spacer_before}px leading spacer, got {}",
1397 origin0.y
1398 );
1399
1400 let scroll = *scroll_slot.borrow().as_ref().expect("scroll state");
1403 scroll.scroll_to(50.0);
1404 assert!(
1405 scroll.value() >= 49.5,
1406 "test setup: content must be tall enough to scroll 50px (got {})",
1407 scroll.value()
1408 );
1409 let (origin1, field_y1) =
1410 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1411 assert!(
1412 (origin1.y - field_y1).abs() < 0.5,
1413 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1414 origin1.y,
1415 field_y1
1416 );
1417 assert!(
1418 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1419 "scrolling 50px must shift the published field origin up by 50px: \
1420 before {}, after {} (expected {})",
1421 origin0.y,
1422 origin1.y,
1423 origin0.y - 50.0
1424 );
1425 }
1426
1427 #[test]
1433 fn window_offset_roundtrip_holds_under_scroll_offset() {
1434 let _app_context = crate::render_state::app_context_test_scope();
1435 let text = "hello world";
1436 let style = TextStyle::default();
1437 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1439 let metrics = TextFieldHandleMetrics {
1440 focused: true,
1441 direct_manipulation: true,
1442 node_origin,
1443 padding_left: 4.0,
1444 padding_top: 3.0,
1445 scroll_offset: 0.0,
1446 line_height: 18.0,
1447 glyph_box: (0.0, 18.0),
1448 wrap_width: None,
1449 };
1450 for offset in 0..=text.len() {
1451 if !text.is_char_boundary(offset) {
1452 continue;
1453 }
1454 let tip =
1457 handle_tip_window_pos(text, &style, &metrics, offset, LineAffinity::Downstream);
1458 let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1459 assert_eq!(
1460 resolved, offset,
1461 "finger at the tip of offset {offset} must map back to it \
1462 under origin {node_origin:?}, got {resolved}"
1463 );
1464 }
1465 }
1466 }
1467
1468 #[test]
1476 fn shared_wrap_boundary_anchors_by_handle_affinity() {
1477 let _app_context = crate::render_state::app_context_test_scope();
1478 with_test_runtime(|| {
1479 let text = "aaaaaaaaaaaaaaaaaaaaaaaa";
1480 let style = TextStyle::default();
1481 let annotated = crate::text::AnnotatedString::from(text);
1482 let full = crate::text::measure_text(&annotated, &style);
1483 let wrap_width = full.width / 3.0;
1485 let ranges = crate::text::wrapped_line_ranges(
1486 None,
1487 &annotated,
1488 &style,
1489 crate::text::TextLayoutOptions::default(),
1490 Some(wrap_width),
1491 );
1492 assert!(
1493 ranges.len() >= 2,
1494 "test setup: text must wrap, got {ranges:?}"
1495 );
1496 let boundary = ranges[1].start;
1497 assert_eq!(
1498 ranges[0].end, boundary,
1499 "test setup: a mid-word wrap must share its boundary byte, got {ranges:?}"
1500 );
1501
1502 let line_height = 20.0;
1503 let metrics = TextFieldHandleMetrics {
1504 focused: true,
1505 direct_manipulation: true,
1506 node_origin: Point { x: 0.0, y: 0.0 },
1507 padding_left: 0.0,
1508 padding_top: 0.0,
1509 scroll_offset: 0.0,
1510 line_height,
1511 glyph_box: (0.0, line_height),
1512 wrap_width: Some(wrap_width),
1513 };
1514
1515 let end_tip =
1516 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Upstream);
1517 assert!(
1518 (end_tip.y - line_height).abs() < 0.5,
1519 "end handle must sit on the UPPER line's bottom ({line_height}), got y={}",
1520 end_tip.y
1521 );
1522 assert!(
1523 end_tip.x > 1.0,
1524 "end handle must sit at the upper line's right edge, got x={}",
1525 end_tip.x
1526 );
1527
1528 let start_tip =
1529 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Downstream);
1530 assert!(
1531 (start_tip.y - 2.0 * line_height).abs() < 0.5,
1532 "start handle must sit on the LOWER line's bottom ({}), got y={}",
1533 2.0 * line_height,
1534 start_tip.y
1535 );
1536 assert!(
1537 start_tip.x.abs() < 0.5,
1538 "start handle must sit at the lower line's left edge, got x={}",
1539 start_tip.x
1540 );
1541
1542 let mut grab = HandleGrabOffset::begin(end_tip.y, end_tip.y);
1548 for finger_y in [
1549 end_tip.y,
1550 end_tip.y + 8.0,
1551 end_tip.y + 32.0,
1552 end_tip.y + 80.0,
1553 ] {
1554 let bias = grab.track(finger_y);
1555 let resolved = window_pos_to_offset(
1556 text,
1557 &style,
1558 &metrics,
1559 Point {
1560 x: end_tip.x,
1561 y: finger_y,
1562 },
1563 bias,
1564 );
1565 let resolved_tip =
1566 handle_tip_window_pos(text, &style, &metrics, resolved, LineAffinity::Upstream);
1567 let target_tip_y =
1568 (finger_y + bias).clamp(line_height, ranges.len() as f32 * line_height);
1569 assert!(
1570 (resolved_tip.y - target_tip_y).abs() <= line_height * 0.5 + 0.5,
1571 "finger y={finger_y}, bias={bias} resolved to offset {resolved} at y={}, expected the nearest visual-line bottom to {}",
1572 resolved_tip.y,
1573 target_tip_y,
1574 );
1575 }
1576 })
1577 }
1578
1579 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1580 use crate::renderer::RenderOp;
1581 scene
1582 .operations()
1583 .iter()
1584 .filter_map(|op| match op {
1585 RenderOp::Text { value, .. } => Some(value.clone()),
1586 _ => None,
1587 })
1588 .collect()
1589 }
1590
1591 fn render_caret_action_menu(
1595 can_paste: bool,
1596 can_undo: bool,
1597 can_redo: bool,
1598 ) -> crate::renderer::RecordedRenderScene {
1599 use cranpose_ui_graphics::Size;
1600
1601 use crate::{layout::LayoutEngine, renderer::HeadlessRenderer, widgets::PopupHost};
1602
1603 let mut composition = Composition::new(MemoryApplier::new());
1604 let key = location_key(file!(), line!(), column!());
1605
1606 let mut content = move || {
1607 PopupHost(move || {
1608 CaretActionMenu(
1609 40.0,
1610 60.0,
1611 true,
1612 can_paste,
1613 can_undo,
1614 can_redo,
1615 || {},
1616 || {},
1617 || {},
1618 || {},
1619 );
1620 });
1621 };
1622
1623 composition.render(key, &mut content).expect("render");
1624 for _ in 0..16 {
1625 if !composition.should_render() {
1626 break;
1627 }
1628 composition.reconcile(key, &mut content).expect("reconcile");
1629 }
1630 let root = composition.root().expect("root");
1631 let handle = composition.runtime_handle();
1632 let mut applier = composition.applier_mut();
1633 applier.set_runtime_handle(handle);
1634 let layout = applier
1635 .compute_layout(
1636 root,
1637 Size {
1638 width: 400.0,
1639 height: 400.0,
1640 },
1641 )
1642 .expect("layout");
1643 applier.clear_runtime_handle();
1644 drop(applier);
1645 HeadlessRenderer::new().render(&layout)
1646 }
1647
1648 #[test]
1652 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1653 let _app_context = crate::render_state::app_context_test_scope();
1654
1655 let all = text_values(&render_caret_action_menu(true, true, true));
1656 for label in ["Paste", "Select all", "Undo", "Redo"] {
1657 assert!(
1658 all.iter().any(|t| t == label),
1659 "caret menu should show {label:?}, got {all:?}"
1660 );
1661 }
1662
1663 let bare = text_values(&render_caret_action_menu(false, false, false));
1665 assert!(
1666 bare.iter().any(|t| t == "Select all"),
1667 "Select all is always available, got {bare:?}"
1668 );
1669 assert!(
1670 !bare
1671 .iter()
1672 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1673 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1674 );
1675 }
1676
1677 #[test]
1678 fn context_menu_shows_for_pointer_selection_on_every_platform() {
1679 let _app_context = crate::render_state::app_context_test_scope();
1680
1681 let touch = text_values(&render_range_menu(true));
1682 assert!(
1683 touch.iter().any(|t| t == "Copy"),
1684 "touch selection should show the Copy menu item, got {touch:?}"
1685 );
1686 assert!(
1687 touch.iter().any(|t| t == "Cut"),
1688 "expected Cut, got {touch:?}"
1689 );
1690 assert!(
1691 touch.iter().any(|t| t == "Select all"),
1692 "expected Select all, got {touch:?}"
1693 );
1694
1695 let mouse = text_values(&render_range_menu(true));
1696 assert!(
1697 mouse.iter().any(|t| t == "Copy"),
1698 "mouse selection must expose the same direct-manipulation menu, got {mouse:?}"
1699 );
1700 let keyboard = text_values(&render_range_menu(false));
1701 assert!(
1702 !keyboard.iter().any(|t| t == "Copy"),
1703 "keyboard-only focus must keep a clean caret, got {keyboard:?}"
1704 );
1705 }
1706
1707 #[test]
1708 fn selection_handles_and_menu_survive_subcomposition() {
1709 let _app_context = crate::render_state::app_context_test_scope();
1715 let scene = render_range_menu_subcomposed(true);
1716
1717 let texts = text_values(&scene);
1718 assert!(
1719 texts.iter().any(|t| t == "Copy"),
1720 "a touch selection inside a subcomposition should show the Copy menu \
1721 item through the host, got {texts:?}"
1722 );
1723 assert!(
1724 texts.iter().any(|t| t == "Select all"),
1725 "expected Select all inside a subcomposition, got {texts:?}"
1726 );
1727 assert_eq!(
1728 image_count(&scene),
1729 2,
1730 "a touch range selection should show two finger teardrop handles in \
1731 the overlay across the subcomposition boundary"
1732 );
1733 }
1734
1735 #[test]
1736 fn selection_handles_and_menu_survive_lazy_column_item() {
1737 let _app_context = crate::render_state::app_context_test_scope();
1744 let scene = render_range_menu_lazy_column(true);
1745
1746 let texts = text_values(&scene);
1747 assert!(
1748 texts.iter().any(|t| t == "Copy"),
1749 "a touch selection inside a LazyColumn item should show the Copy menu \
1750 item through the host, got {texts:?}"
1751 );
1752 assert!(
1753 texts.iter().any(|t| t == "Select all"),
1754 "expected Select all inside a LazyColumn item, got {texts:?}"
1755 );
1756 assert_eq!(
1757 image_count(&scene),
1758 2,
1759 "a touch range selection should show two finger teardrop handles in \
1760 the overlay across the LazyColumn item subcomposition boundary"
1761 );
1762 }
1763
1764 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1765 use cranpose_ui_graphics::DrawPrimitive;
1766
1767 use crate::renderer::RenderOp;
1768 scene
1769 .operations()
1770 .iter()
1771 .filter(|op| {
1772 matches!(
1773 op,
1774 RenderOp::Primitive {
1775 primitive: DrawPrimitive::Image { .. },
1776 ..
1777 }
1778 )
1779 })
1780 .count()
1781 }
1782
1783 #[test]
1784 fn cursor_handle_shows_for_pointer_selection_on_every_platform() {
1785 let _app_context = crate::render_state::app_context_test_scope();
1786 assert_eq!(
1787 image_count(&render_collapsed_handles(true)),
1788 1,
1789 "a touch caret should show one finger cursor handle in the overlay"
1790 );
1791 assert_eq!(
1792 image_count(&render_collapsed_handles(true)),
1793 1,
1794 "a mouse-created caret should expose its draggable handle"
1795 );
1796 assert_eq!(
1797 image_count(&render_collapsed_handles(false)),
1798 0,
1799 "keyboard-only focus should keep a clean caret"
1800 );
1801 }
1802
1803 #[test]
1804 fn basic_text_field_creates_node() {
1805 let _app_context = crate::render_state::app_context_test_scope();
1806 let mut composition = Composition::new(MemoryApplier::new());
1807 let state = TextFieldState::new("Test content");
1808
1809 let result = composition.render(location_key(file!(), line!(), column!()), move || {
1810 BasicTextField(state, Modifier::empty(), TextStyle::default());
1811 });
1812
1813 assert!(result.is_ok());
1814 assert!(composition.root().is_some());
1815 }
1816
1817 #[test]
1818 fn basic_text_field_state_updates() {
1819 let _app_context = crate::render_state::app_context_test_scope();
1820 with_test_runtime(|| {
1821 let state = TextFieldState::new("Hello");
1822 assert_eq!(state.text(), "Hello");
1823
1824 state.edit(|buffer| {
1825 buffer.place_cursor_at_end();
1826 buffer.insert("!");
1827 });
1828
1829 assert_eq!(state.text(), "Hello!");
1830 });
1831 }
1832
1833 #[test]
1842 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1843 use std::cell::RefCell;
1844
1845 use cranpose_core::Key;
1846 use cranpose_foundation::lazy::{LazyListScope, LazyListState, rememberLazyListState};
1847 use cranpose_ui_graphics::Size;
1848
1849 use crate::{
1850 LazyColumn, LazyColumnSpec,
1851 bring_into_view::local_bring_into_view_responder,
1852 layout::LayoutEngine,
1853 renderer::HeadlessRenderer,
1854 widgets::{Box, BoxSpec, PopupHost},
1855 };
1856
1857 let _app_context = crate::render_state::app_context_test_scope();
1858 let mut composition = Composition::new(MemoryApplier::new());
1859 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1860 Rc::new(RefCell::new(None));
1861 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1862
1863 let mut content = {
1866 let responder_slot = Rc::clone(&responder_slot);
1867 let state_slot = Rc::clone(&state_slot);
1868 move || {
1869 let responder_slot = Rc::clone(&responder_slot);
1870 let state_slot = Rc::clone(&state_slot);
1871 PopupHost(move || {
1872 let list_state = rememberLazyListState();
1873 *state_slot.borrow_mut() = Some(list_state);
1874 let responder_slot = Rc::clone(&responder_slot);
1875 LazyColumn(
1876 Modifier::empty().size(Size {
1877 width: 300.0,
1878 height: 400.0,
1879 }),
1880 list_state,
1881 LazyColumnSpec::default(),
1882 move |scope| {
1883 let responder_slot = Rc::clone(&responder_slot);
1884 scope.items(30, move |_index| {
1885 if responder_slot.borrow().is_none()
1886 && let Some(r) = local_bring_into_view_responder().current()
1887 {
1888 *responder_slot.borrow_mut() = Some(r);
1889 }
1890 Box(
1891 Modifier::empty().size(Size {
1892 width: 300.0,
1893 height: 80.0,
1894 }),
1895 BoxSpec::default(),
1896 || {},
1897 );
1898 });
1899 },
1900 );
1901 });
1902 }
1903 };
1904
1905 fn run_layout(
1906 composition: &mut Composition<MemoryApplier>,
1907 key: Key,
1908 content: &mut dyn FnMut(),
1909 ) {
1910 for _ in 0..16 {
1911 if !composition.should_render() {
1912 break;
1913 }
1914 composition
1915 .reconcile(key, &mut *content)
1916 .expect("reconcile");
1917 }
1918 let root = composition.root().expect("root");
1919 let handle = composition.runtime_handle();
1920 let mut applier = composition.applier_mut();
1921 applier.set_runtime_handle(handle);
1922 let layout = applier
1923 .compute_layout(
1924 root,
1925 Size {
1926 width: 400.0,
1927 height: 600.0,
1928 },
1929 )
1930 .expect("layout");
1931 applier.clear_runtime_handle();
1932 drop(applier);
1933 let _ = HeadlessRenderer::new().render(&layout);
1934 }
1935
1936 let key = location_key(file!(), line!(), column!());
1937 composition.render(key, &mut content).expect("render");
1938 run_layout(&mut composition, key, &mut content);
1939
1940 let responder = responder_slot
1941 .borrow()
1942 .clone()
1943 .expect("LazyColumn provides a bring-into-view responder to its items");
1944 let list_state = state_slot.borrow().expect("list state captured");
1945 let offset0 = list_state.first_visible_item_scroll_offset();
1946 let index0 = list_state.first_visible_item_index();
1947
1948 responder.bring_into_view(
1951 Rect {
1952 x: 10.0,
1953 y: 100.0,
1954 width: 2.0,
1955 height: 20.0,
1956 },
1957 0.0,
1958 );
1959 run_layout(&mut composition, key, &mut content);
1960 assert_eq!(
1961 list_state.first_visible_item_index(),
1962 index0,
1963 "an already-visible caret must not scroll the list"
1964 );
1965 assert!(
1966 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1967 "an already-visible caret must not scroll the list"
1968 );
1969
1970 responder.bring_into_view(
1973 Rect {
1974 x: 10.0,
1975 y: 360.0,
1976 width: 2.0,
1977 height: 20.0,
1978 },
1979 250.0,
1980 );
1981 run_layout(&mut composition, key, &mut content);
1982 let scrolled_forward = list_state.first_visible_item_index() > index0
1983 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1984 assert!(
1985 scrolled_forward,
1986 "a caret behind the keyboard must scroll the list forward \
1987 (index {} -> {}, offset {:.1} -> {:.1})",
1988 index0,
1989 list_state.first_visible_item_index(),
1990 offset0,
1991 list_state.first_visible_item_scroll_offset(),
1992 );
1993 }
1994}