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