1#![allow(non_snake_case)]
7
8use crate::bring_into_view::local_bring_into_view_responder;
9use crate::clipboard_session::{clipboard_read_text, clipboard_write_text};
10use crate::composable;
11use crate::layout::policies::EmptyMeasurePolicy;
12use crate::modifier::Modifier;
13use crate::safe_area::local_ime_insets;
14use crate::text::{measure_text, AnnotatedString, TextStyle};
15use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_paste, dispatch_select_all};
16use crate::text_field_modifier_node::{
17 TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
18};
19use crate::text_selection::{selection_after_handle_drag, HandleKind, HANDLE_RADIUS};
20use crate::widgets::{
21 CaretActionMenu, CursorMagnifier, Layout, SelectionHandle, TextSelectionMenu,
22};
23use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
24use cranpose_foundation::modifier_element;
25use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
26use cranpose_ui_graphics::{Color, Point, Rect};
27use std::cell::Cell;
28use std::rc::Rc;
29
30const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
32
33fn handle_tip_window_pos(
36 text: &str,
37 style: &TextStyle,
38 metrics: &TextFieldHandleMetrics,
39 offset: usize,
40) -> Point {
41 let offset = offset.min(text.len());
42 let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
46 text,
47 style,
48 None,
49 metrics.wrap_width,
50 offset,
51 );
52 let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
53 Point {
54 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
55 y: metrics.node_origin.y
56 + metrics.padding_top
57 + (line_index as f32 + 1.0) * metrics.line_height,
58 }
59}
60
61fn window_pos_to_offset(
64 text: &str,
65 style: &TextStyle,
66 metrics: &TextFieldHandleMetrics,
67 window_pos: Point,
68) -> usize {
69 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
70 + metrics.scroll_offset)
71 .max(0.0);
72 let local_y =
75 (window_pos.y - metrics.node_origin.y - metrics.padding_top - metrics.line_height).max(0.0);
76 crate::text::offset_for_position_wrapped(
81 text,
82 style,
83 None,
84 metrics.wrap_width,
85 metrics.line_height,
86 local_x,
87 local_y,
88 )
89}
90#[composable]
108pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
109 BasicTextFieldWithOptions(
110 state,
111 modifier,
112 BasicTextFieldOptions {
113 text_style: style,
114 ..BasicTextFieldOptions::default()
115 },
116 )
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct BasicTextFieldOptions {
122 pub text_style: TextStyle,
124 pub cursor_color: Color,
126 pub line_limits: TextFieldLineLimits,
128}
129
130impl Default for BasicTextFieldOptions {
131 fn default() -> Self {
132 Self {
133 text_style: TextStyle::default(),
134 cursor_color: Color(0.0, 0.0, 0.0, 1.0), line_limits: TextFieldLineLimits::default(),
136 }
137 }
138}
139
140#[composable]
144pub fn BasicTextFieldWithOptions(
145 state: TextFieldState,
146 modifier: Modifier,
147 options: BasicTextFieldOptions,
148) -> NodeId {
149 let _text = state.text();
152 let _selection = state.selection();
153
154 let controller =
158 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
159
160 let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
162 .with_cursor_color(options.cursor_color)
163 .with_line_limits(options.line_limits)
164 .with_handle_controller(controller.clone());
165
166 let text_field_modifier = modifier_element(text_field_element);
168 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
169 let combined_modifier = modifier.then(final_modifier);
170
171 let node = Layout(
174 combined_modifier,
175 EmptyMeasurePolicy,
176 || {}, );
178
179 BringCaretIntoView(
184 state.clone(),
185 options.text_style.clone(),
186 controller.clone(),
187 );
188
189 SelectionHandles(state, options.text_style, controller);
195
196 node
197}
198
199fn caret_window_rect(
203 text: &str,
204 style: &TextStyle,
205 metrics: &TextFieldHandleMetrics,
206 offset: usize,
207) -> Rect {
208 let tip = handle_tip_window_pos(text, style, metrics, offset);
211 Rect {
212 x: tip.x,
213 y: tip.y - metrics.line_height,
214 width: 2.0,
215 height: metrics.line_height,
216 }
217}
218
219#[composable]
229fn BringCaretIntoView(
230 state: TextFieldState,
231 style: TextStyle,
232 controller: TextFieldHandleController,
233) {
234 let Some(metrics) = controller.metrics() else {
235 return;
236 };
237 let ime_bottom = local_ime_insets().current().bottom;
240 let responder = local_bring_into_view_responder().current();
241
242 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
243 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
244
245 if !metrics.focused {
246 previous.set(None);
249 return;
250 }
251 let Some(responder) = responder else {
252 return;
253 };
254
255 let text = state.text();
256 let selection = state.selection();
257 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
258
259 let key = (
263 selection.start,
264 selection.end,
265 (ime_bottom * 4.0).round() as i64,
266 );
267 SideEffect(move || {
268 if previous.get() != Some(key) {
269 previous.set(Some(key));
270 responder.bring_into_view(caret, ime_bottom);
271 }
272 });
273}
274
275#[composable]
278fn SelectionHandles(
279 state: TextFieldState,
280 style: TextStyle,
281 controller: TextFieldHandleController,
282) {
283 let selection = state.selection();
284 let current_range = (selection.min(), selection.max());
285
286 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
290 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
295 let caret_menu_offset: Rc<Cell<usize>> =
298 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
299 let previous_range: Rc<Cell<(usize, usize)>> =
300 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
301 {
302 let previous_range = Rc::clone(&previous_range);
303 SideEffect(move || {
304 if previous_range.get() != current_range {
305 previous_range.set(current_range);
306 menu_open.set(true);
307 }
308 });
309 }
310 {
313 let caret_menu_offset = Rc::clone(&caret_menu_offset);
314 let caret_start = selection.start;
315 SideEffect(move || {
316 if caret_menu_open.value()
317 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
318 {
319 caret_menu_open.set(false);
320 }
321 });
322 }
323
324 let Some(metrics) = controller.metrics() else {
325 return;
326 };
327 if !metrics.focused || !metrics.touch {
328 return;
329 }
330
331 let text = state.text();
332
333 let drag_pos: MutableState<Option<Point>> =
337 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
338
339 if selection.collapsed() {
340 let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
342 let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
343 let open_caret_menu = {
346 let caret_menu_offset = Rc::clone(&caret_menu_offset);
347 let state = state.clone();
348 move || {
349 caret_menu_offset.set(state.selection().start);
350 caret_menu_open.set(true);
351 }
352 };
353 let on_tap = open_caret_menu.clone();
354 let on_long_press = open_caret_menu;
355 SelectionHandle(
356 HandleKind::Cursor,
357 tip,
358 HANDLE_RADIUS,
359 HANDLE_COLOR,
360 move |pos| {
361 drag_pos.set(Some(pos));
362 on_drag(pos);
363 },
364 move || drag_pos.set(None),
365 on_long_press,
366 on_tap,
367 );
368
369 if caret_menu_open.value() {
372 let caret_anchor = Point {
373 x: tip.x,
374 y: tip.y - metrics.line_height,
375 };
376 let can_paste = clipboard_read_text().is_some();
377 let can_undo = state.can_undo();
378 let can_redo = state.can_redo();
379 let undo_state = state.clone();
380 let redo_state = state.clone();
381 CaretActionMenu(
382 caret_anchor,
383 can_paste,
384 can_undo,
385 can_redo,
386 move || {
387 if let Some(text) = clipboard_read_text() {
388 dispatch_paste(&text);
389 }
390 caret_menu_open.set(false);
391 },
392 move || {
393 dispatch_select_all();
394 caret_menu_open.set(false);
395 },
396 move || {
397 undo_state.undo();
398 crate::request_render_invalidation();
399 caret_menu_open.set(false);
400 },
401 move || {
402 redo_state.redo();
403 crate::request_render_invalidation();
404 caret_menu_open.set(false);
405 },
406 );
407 }
408 } else {
409 let start = selection.min();
411 let end = selection.max();
412 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
413 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
414
415 let on_drag_start = drag_edge_closure(
416 HandleKind::SelectionStart,
417 state.clone(),
418 style.clone(),
419 controller.clone(),
420 );
421 SelectionHandle(
422 HandleKind::SelectionStart,
423 start_tip,
424 HANDLE_RADIUS,
425 HANDLE_COLOR,
426 move |pos| {
427 drag_pos.set(Some(pos));
428 on_drag_start(pos);
429 },
430 move || drag_pos.set(None),
431 move || menu_open.set(true),
435 move || menu_open.set(true),
437 );
438
439 let on_drag_end = drag_edge_closure(
440 HandleKind::SelectionEnd,
441 state.clone(),
442 style.clone(),
443 controller.clone(),
444 );
445 SelectionHandle(
446 HandleKind::SelectionEnd,
447 end_tip,
448 HANDLE_RADIUS,
449 HANDLE_COLOR,
450 move |pos| {
451 drag_pos.set(Some(pos));
452 on_drag_end(pos);
453 },
454 move || drag_pos.set(None),
455 move || menu_open.set(true),
456 move || menu_open.set(true),
457 );
458
459 if menu_open.value() {
462 let menu_anchor = Point {
464 x: (start_tip.x + end_tip.x) * 0.5,
465 y: start_tip.y - metrics.line_height,
466 };
467 let can_paste = clipboard_read_text().is_some();
468 TextSelectionMenu(
469 menu_anchor,
470 can_paste,
471 move || {
472 if let Some(text) = dispatch_copy() {
473 clipboard_write_text(&text);
474 }
475 menu_open.set(false);
476 },
477 move || {
478 if let Some(text) = dispatch_cut() {
479 clipboard_write_text(&text);
480 }
481 menu_open.set(false);
482 },
483 move || {
484 if let Some(text) = clipboard_read_text() {
485 dispatch_paste(&text);
486 }
487 menu_open.set(false);
488 },
489 move || {
490 dispatch_select_all();
491 menu_open.set(false);
492 },
493 );
494 }
495 }
496
497 if let Some(finger) = drag_pos.value() {
504 let offset = window_pos_to_offset(&text, &style, &metrics, finger);
505 CursorMagnifier(text.clone(), style.clone(), metrics, offset, finger);
506 }
507}
508
509fn drag_caret_closure(
512 state: TextFieldState,
513 style: TextStyle,
514 controller: TextFieldHandleController,
515) -> Rc<dyn Fn(Point)> {
516 Rc::new(move |window_pos: Point| {
517 let Some(metrics) = controller.metrics() else {
518 return;
519 };
520 let text = state.text();
521 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
522 state.set_selection(TextRange::new(offset, offset));
523 crate::request_render_invalidation();
524 })
525}
526
527fn drag_edge_closure(
531 dragged: HandleKind,
532 state: TextFieldState,
533 style: TextStyle,
534 controller: TextFieldHandleController,
535) -> Rc<dyn Fn(Point)> {
536 Rc::new(move |window_pos: Point| {
537 let Some(metrics) = controller.metrics() else {
538 return;
539 };
540 let text = state.text();
541 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
542 let selection = state.selection();
543 let fixed_edge = match dragged {
544 HandleKind::SelectionStart => selection.max(),
545 _ => selection.min(),
546 };
547 let (min, max) =
548 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
549 state.set_selection(TextRange::new(min, max));
550 crate::request_render_invalidation();
551 })
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
558 use std::sync::Arc;
559
560 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
562 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
563 f()
564 }
565
566 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
570 use crate::layout::LayoutEngine;
571 use crate::renderer::HeadlessRenderer;
572 use crate::widgets::PopupHost;
573 use cranpose_ui_graphics::Size;
574
575 let mut composition = Composition::new(MemoryApplier::new());
576 let key = location_key(file!(), line!(), column!());
577 let state = TextFieldState::new("hello world");
578
579 let mut content = {
580 let state = state.clone();
581 move || {
582 let state = state.clone();
583 PopupHost(move || {
584 let controller = TextFieldHandleController::new();
585 controller.publish(TextFieldHandleMetrics {
586 focused: true,
587 touch,
588 node_origin: Point { x: 0.0, y: 10.0 },
589 padding_left: 0.0,
590 padding_top: 0.0,
591 scroll_offset: 0.0,
592 line_height: 18.0,
593 wrap_width: None,
594 });
595 SelectionHandles(state.clone(), TextStyle::default(), controller);
596 });
597 }
598 };
599
600 composition.render(key, &mut content).expect("render");
601 for _ in 0..16 {
602 if !composition.should_render() {
603 break;
604 }
605 composition.reconcile(key, &mut content).expect("reconcile");
606 }
607 let root = composition.root().expect("root");
608 let handle = composition.runtime_handle();
609 let mut applier = composition.applier_mut();
610 applier.set_runtime_handle(handle);
611 let layout = applier
612 .compute_layout(
613 root,
614 Size {
615 width: 400.0,
616 height: 400.0,
617 },
618 )
619 .expect("layout");
620 applier.clear_runtime_handle();
621 drop(applier);
622 HeadlessRenderer::new().render(&layout)
623 }
624
625 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
628 use crate::layout::LayoutEngine;
629 use crate::renderer::HeadlessRenderer;
630 use crate::widgets::PopupHost;
631 use cranpose_ui_graphics::Size;
632
633 let mut composition = Composition::new(MemoryApplier::new());
634 let key = location_key(file!(), line!(), column!());
635 let state = TextFieldState::new("hello world");
636
637 let mut content = {
638 let state = state.clone();
639 move || {
640 let state = state.clone();
641 PopupHost(move || {
642 let controller = TextFieldHandleController::new();
643 if state.selection() != TextRange::new(0, 5) {
644 state.set_selection(TextRange::new(0, 5));
645 }
646 controller.publish(TextFieldHandleMetrics {
647 focused: true,
648 touch,
649 node_origin: Point { x: 0.0, y: 40.0 },
650 padding_left: 0.0,
651 padding_top: 0.0,
652 scroll_offset: 0.0,
653 line_height: 18.0,
654 wrap_width: None,
655 });
656 SelectionHandles(state.clone(), TextStyle::default(), controller);
657 });
658 }
659 };
660
661 composition.render(key, &mut content).expect("render");
662 for _ in 0..16 {
663 if !composition.should_render() {
664 break;
665 }
666 composition.reconcile(key, &mut content).expect("reconcile");
667 }
668 let root = composition.root().expect("root");
669 let handle = composition.runtime_handle();
670 let mut applier = composition.applier_mut();
671 applier.set_runtime_handle(handle);
672 let layout = applier
673 .compute_layout(
674 root,
675 Size {
676 width: 400.0,
677 height: 400.0,
678 },
679 )
680 .expect("layout");
681 applier.clear_runtime_handle();
682 drop(applier);
683 HeadlessRenderer::new().render(&layout)
684 }
685
686 fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
692 use crate::layout::LayoutEngine;
693 use crate::renderer::HeadlessRenderer;
694 use crate::widgets::{BoxWithConstraints, PopupHost};
695 use cranpose_ui_graphics::Size;
696
697 let mut composition = Composition::new(MemoryApplier::new());
698 let key = location_key(file!(), line!(), column!());
699 let state = TextFieldState::new("hello world");
700
701 let mut content = {
702 let state = state.clone();
703 move || {
704 let state = state.clone();
705 PopupHost(move || {
706 let state = state.clone();
707 BoxWithConstraints(
708 Modifier::empty().size(Size {
709 width: 300.0,
710 height: 300.0,
711 }),
712 move |_scope| {
713 let controller = TextFieldHandleController::new();
714 if state.selection() != TextRange::new(0, 5) {
715 state.set_selection(TextRange::new(0, 5));
716 }
717 controller.publish(TextFieldHandleMetrics {
718 focused: true,
719 touch,
720 node_origin: Point { x: 0.0, y: 40.0 },
721 padding_left: 0.0,
722 padding_top: 0.0,
723 scroll_offset: 0.0,
724 line_height: 18.0,
725 wrap_width: None,
726 });
727 SelectionHandles(state.clone(), TextStyle::default(), controller);
728 },
729 );
730 });
731 }
732 };
733
734 composition.render(key, &mut content).expect("render");
735 let root = composition.root().expect("root");
736 let handle = composition.runtime_handle();
737 let mut scene = None;
738 for _ in 0..8 {
742 for _ in 0..16 {
743 if !composition.should_render() {
744 break;
745 }
746 composition.reconcile(key, &mut content).expect("reconcile");
747 }
748 let mut applier = composition.applier_mut();
749 applier.set_runtime_handle(handle.clone());
750 let layout = applier
751 .compute_layout(
752 root,
753 Size {
754 width: 400.0,
755 height: 400.0,
756 },
757 )
758 .expect("layout");
759 applier.clear_runtime_handle();
760 drop(applier);
761 scene = Some(HeadlessRenderer::new().render(&layout));
762 }
763 scene.expect("scene")
764 }
765
766 fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
773 use crate::layout::LayoutEngine;
774 use crate::renderer::HeadlessRenderer;
775 use crate::widgets::PopupHost;
776 use crate::{LazyColumn, LazyColumnSpec};
777 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
778 use cranpose_ui_graphics::Size;
779
780 let mut composition = Composition::new(MemoryApplier::new());
781 let key = location_key(file!(), line!(), column!());
782 let state = TextFieldState::new("hello world");
783
784 let mut content = {
785 let state = state.clone();
786 move || {
787 let state = state.clone();
788 PopupHost(move || {
789 let state = state.clone();
790 let list_state = remember_lazy_list_state();
791 LazyColumn(
792 Modifier::empty().size(Size {
793 width: 300.0,
794 height: 300.0,
795 }),
796 list_state,
797 LazyColumnSpec::default(),
798 move |scope| {
799 let state = state.clone();
800 scope.items(
801 1,
802 None::<fn(usize) -> u64>,
803 None::<fn(usize) -> u64>,
804 move |_index| {
805 let controller = TextFieldHandleController::new();
806 if state.selection() != TextRange::new(0, 5) {
807 state.set_selection(TextRange::new(0, 5));
808 }
809 controller.publish(TextFieldHandleMetrics {
810 focused: true,
811 touch,
812 node_origin: Point { x: 0.0, y: 40.0 },
813 padding_left: 0.0,
814 padding_top: 0.0,
815 scroll_offset: 0.0,
816 line_height: 18.0,
817 wrap_width: None,
818 });
819 SelectionHandles(
820 state.clone(),
821 TextStyle::default(),
822 controller,
823 );
824 },
825 );
826 },
827 );
828 });
829 }
830 };
831
832 composition.render(key, &mut content).expect("render");
833 let root = composition.root().expect("root");
834 let handle = composition.runtime_handle();
835 let mut scene = None;
836 for _ in 0..8 {
837 for _ in 0..16 {
838 if !composition.should_render() {
839 break;
840 }
841 composition.reconcile(key, &mut content).expect("reconcile");
842 }
843 let mut applier = composition.applier_mut();
844 applier.set_runtime_handle(handle.clone());
845 let layout = applier
846 .compute_layout(
847 root,
848 Size {
849 width: 400.0,
850 height: 400.0,
851 },
852 )
853 .expect("layout");
854 applier.clear_runtime_handle();
855 drop(applier);
856 scene = Some(HeadlessRenderer::new().render(&layout));
857 }
858 scene.expect("scene")
859 }
860
861 #[test]
870 fn field_window_origin_follows_vertical_scroll() {
871 use crate::layout::policies::EmptyMeasurePolicy;
872 use crate::layout::{LayoutBox, LayoutEngine};
873 use crate::renderer::HeadlessRenderer;
874 use crate::scroll::ScrollState;
875 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
876 use cranpose_core::{remember, Key};
877 use cranpose_foundation::modifier_element;
878 use cranpose_ui_graphics::Size;
879 use std::cell::RefCell;
880
881 let _app_context = crate::render_state::app_context_test_scope();
882
883 let mut composition = Composition::new(MemoryApplier::new());
886 let state = TextFieldState::new("hello world");
887 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
888 Rc::new(RefCell::new(None));
889 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
892
893 let spacer_before = 200.0_f32;
894 let mut content = {
895 let state = state.clone();
896 let controller_slot = Rc::clone(&controller_slot);
897 let scroll_slot = Rc::clone(&scroll_slot);
898 move || {
899 let state = state.clone();
900 let controller_slot = Rc::clone(&controller_slot);
901 let scroll_slot = Rc::clone(&scroll_slot);
902 PopupHost(move || {
903 let controller = remember(TextFieldHandleController::new)
904 .with(TextFieldHandleController::clone);
905 *controller_slot.borrow_mut() = Some(controller.clone());
906 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
907 *scroll_slot.borrow_mut() = Some(scroll.clone());
908 let state = state.clone();
909 let controller = controller.clone();
910 Column(
911 Modifier::empty()
912 .size(Size {
913 width: 300.0,
914 height: 150.0,
915 })
916 .vertical_scroll(scroll.clone(), false),
917 ColumnSpec::default(),
918 move || {
919 Spacer(Size {
920 width: 300.0,
921 height: spacer_before,
922 });
923 let element =
924 TextFieldElement::new(state.clone(), TextStyle::default())
925 .with_handle_controller(controller.clone());
926 let field_modifier =
927 Modifier::from_parts(vec![modifier_element(element)]);
928 Layout(field_modifier, EmptyMeasurePolicy, || {});
929 Spacer(Size {
930 width: 300.0,
931 height: 400.0,
932 });
933 },
934 );
935 });
936 }
937 };
938
939 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
941 if node
942 .node_data
943 .modifier_slices()
944 .text_field_window_origin()
945 .is_some()
946 {
947 return Some(node.rect);
948 }
949 node.children.iter().find_map(find_field_rect)
950 }
951
952 fn layout_and_read(
953 composition: &mut Composition<MemoryApplier>,
954 key: Key,
955 content: &mut dyn FnMut(),
956 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
957 ) -> (Point, f32) {
958 for _ in 0..16 {
959 if !composition.should_render() {
960 break;
961 }
962 composition
963 .reconcile(key, &mut *content)
964 .expect("reconcile");
965 }
966 let root = composition.root().expect("root");
967 let handle = composition.runtime_handle();
968 let mut applier = composition.applier_mut();
969 applier.set_runtime_handle(handle);
970 let layout = applier
971 .compute_layout(
972 root,
973 cranpose_ui_graphics::Size {
974 width: 400.0,
975 height: 600.0,
976 },
977 )
978 .expect("layout");
979 applier.clear_runtime_handle();
980 drop(applier);
981 let _ = HeadlessRenderer::new().render(&layout);
982 let field_y = find_field_rect(layout.root()).expect("field placed").y;
983 let node_origin = controller_slot
984 .borrow()
985 .as_ref()
986 .expect("controller")
987 .metrics()
988 .expect("metrics published")
989 .node_origin;
990 (node_origin, field_y)
991 }
992
993 let key = location_key(file!(), line!(), column!());
994 composition.render(key, &mut content).expect("render");
995
996 let (origin0, field_y0) =
997 layout_and_read(&mut composition, key, &mut content, &controller_slot);
998 assert!(
1001 (origin0.y - field_y0).abs() < 0.5,
1002 "published node_origin.y {} must equal the field's placed window-y {}",
1003 origin0.y,
1004 field_y0
1005 );
1006 assert!(
1007 origin0.y >= spacer_before - 0.5,
1008 "field should start at/after the {spacer_before}px leading spacer, got {}",
1009 origin0.y
1010 );
1011
1012 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
1015 scroll.scroll_to(50.0);
1016 assert!(
1017 scroll.value() >= 49.5,
1018 "test setup: content must be tall enough to scroll 50px (got {})",
1019 scroll.value()
1020 );
1021 let (origin1, field_y1) =
1022 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1023 assert!(
1024 (origin1.y - field_y1).abs() < 0.5,
1025 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1026 origin1.y,
1027 field_y1
1028 );
1029 assert!(
1030 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1031 "scrolling 50px must shift the published field origin up by 50px: \
1032 before {}, after {} (expected {})",
1033 origin0.y,
1034 origin1.y,
1035 origin0.y - 50.0
1036 );
1037 }
1038
1039 #[test]
1045 fn window_offset_roundtrip_holds_under_scroll_offset() {
1046 let _app_context = crate::render_state::app_context_test_scope();
1047 let text = "hello world";
1048 let style = TextStyle::default();
1049 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1051 let metrics = TextFieldHandleMetrics {
1052 focused: true,
1053 touch: true,
1054 node_origin,
1055 padding_left: 4.0,
1056 padding_top: 3.0,
1057 scroll_offset: 0.0,
1058 line_height: 18.0,
1059 wrap_width: None,
1060 };
1061 for offset in 0..=text.len() {
1062 if !text.is_char_boundary(offset) {
1063 continue;
1064 }
1065 let tip = handle_tip_window_pos(text, &style, &metrics, offset);
1066 let resolved = window_pos_to_offset(text, &style, &metrics, tip);
1067 assert_eq!(
1068 resolved, offset,
1069 "finger at the tip of offset {offset} must map back to it \
1070 under origin {node_origin:?}, got {resolved}"
1071 );
1072 }
1073 }
1074 }
1075
1076 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1077 use crate::renderer::RenderOp;
1078 scene
1079 .operations()
1080 .iter()
1081 .filter_map(|op| match op {
1082 RenderOp::Text { value, .. } => Some(value.clone()),
1083 _ => None,
1084 })
1085 .collect()
1086 }
1087
1088 fn render_caret_action_menu(
1092 can_paste: bool,
1093 can_undo: bool,
1094 can_redo: bool,
1095 ) -> crate::renderer::RecordedRenderScene {
1096 use crate::layout::LayoutEngine;
1097 use crate::renderer::HeadlessRenderer;
1098 use crate::widgets::PopupHost;
1099 use cranpose_ui_graphics::Size;
1100
1101 let mut composition = Composition::new(MemoryApplier::new());
1102 let key = location_key(file!(), line!(), column!());
1103
1104 let mut content = move || {
1105 PopupHost(move || {
1106 CaretActionMenu(
1107 Point { x: 40.0, y: 60.0 },
1108 can_paste,
1109 can_undo,
1110 can_redo,
1111 || {},
1112 || {},
1113 || {},
1114 || {},
1115 );
1116 });
1117 };
1118
1119 composition.render(key, &mut content).expect("render");
1120 for _ in 0..16 {
1121 if !composition.should_render() {
1122 break;
1123 }
1124 composition.reconcile(key, &mut content).expect("reconcile");
1125 }
1126 let root = composition.root().expect("root");
1127 let handle = composition.runtime_handle();
1128 let mut applier = composition.applier_mut();
1129 applier.set_runtime_handle(handle);
1130 let layout = applier
1131 .compute_layout(
1132 root,
1133 Size {
1134 width: 400.0,
1135 height: 400.0,
1136 },
1137 )
1138 .expect("layout");
1139 applier.clear_runtime_handle();
1140 drop(applier);
1141 HeadlessRenderer::new().render(&layout)
1142 }
1143
1144 #[test]
1148 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1149 let _app_context = crate::render_state::app_context_test_scope();
1150
1151 let all = text_values(&render_caret_action_menu(true, true, true));
1152 for label in ["Paste", "Select all", "Undo", "Redo"] {
1153 assert!(
1154 all.iter().any(|t| t == label),
1155 "caret menu should show {label:?}, got {all:?}"
1156 );
1157 }
1158
1159 let bare = text_values(&render_caret_action_menu(false, false, false));
1161 assert!(
1162 bare.iter().any(|t| t == "Select all"),
1163 "Select all is always available, got {bare:?}"
1164 );
1165 assert!(
1166 !bare
1167 .iter()
1168 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1169 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1170 );
1171 }
1172
1173 #[test]
1174 fn context_menu_shows_for_touch_selection_only() {
1175 let _app_context = crate::render_state::app_context_test_scope();
1176
1177 let touch = text_values(&render_range_menu(true));
1178 assert!(
1179 touch.iter().any(|t| t == "Copy"),
1180 "touch selection should show the Copy menu item, got {touch:?}"
1181 );
1182 assert!(
1183 touch.iter().any(|t| t == "Cut"),
1184 "expected Cut, got {touch:?}"
1185 );
1186 assert!(
1187 touch.iter().any(|t| t == "Select all"),
1188 "expected Select all, got {touch:?}"
1189 );
1190
1191 let mouse = text_values(&render_range_menu(false));
1192 assert!(
1193 !mouse.iter().any(|t| t == "Copy"),
1194 "mouse selection must not show the finger contextual menu, got {mouse:?}"
1195 );
1196 }
1197
1198 #[test]
1199 fn selection_handles_and_menu_survive_subcomposition() {
1200 let _app_context = crate::render_state::app_context_test_scope();
1206 let scene = render_range_menu_subcomposed(true);
1207
1208 let texts = text_values(&scene);
1209 assert!(
1210 texts.iter().any(|t| t == "Copy"),
1211 "a touch selection inside a subcomposition should show the Copy menu \
1212 item through the host, got {texts:?}"
1213 );
1214 assert!(
1215 texts.iter().any(|t| t == "Select all"),
1216 "expected Select all inside a subcomposition, got {texts:?}"
1217 );
1218 assert_eq!(
1219 image_count(&scene),
1220 2,
1221 "a touch range selection should show two finger teardrop handles in \
1222 the overlay across the subcomposition boundary"
1223 );
1224 }
1225
1226 #[test]
1227 fn selection_handles_and_menu_survive_lazy_column_item() {
1228 let _app_context = crate::render_state::app_context_test_scope();
1235 let scene = render_range_menu_lazy_column(true);
1236
1237 let texts = text_values(&scene);
1238 assert!(
1239 texts.iter().any(|t| t == "Copy"),
1240 "a touch selection inside a LazyColumn item should show the Copy menu \
1241 item through the host, got {texts:?}"
1242 );
1243 assert!(
1244 texts.iter().any(|t| t == "Select all"),
1245 "expected Select all inside a LazyColumn item, got {texts:?}"
1246 );
1247 assert_eq!(
1248 image_count(&scene),
1249 2,
1250 "a touch range selection should show two finger teardrop handles in \
1251 the overlay across the LazyColumn item subcomposition boundary"
1252 );
1253 }
1254
1255 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1256 use crate::renderer::RenderOp;
1257 use cranpose_ui_graphics::DrawPrimitive;
1258 scene
1259 .operations()
1260 .iter()
1261 .filter(|op| {
1262 matches!(
1263 op,
1264 RenderOp::Primitive {
1265 primitive: DrawPrimitive::Image { .. },
1266 ..
1267 }
1268 )
1269 })
1270 .count()
1271 }
1272
1273 #[test]
1274 fn cursor_handle_shows_for_touch_only() {
1275 let _app_context = crate::render_state::app_context_test_scope();
1276 assert_eq!(
1277 image_count(&render_collapsed_handles(true)),
1278 1,
1279 "a touch caret should show one finger cursor handle in the overlay"
1280 );
1281 assert_eq!(
1282 image_count(&render_collapsed_handles(false)),
1283 0,
1284 "a mouse caret should keep a clean caret with no finger handle"
1285 );
1286 }
1287
1288 #[test]
1289 fn basic_text_field_creates_node() {
1290 let _app_context = crate::render_state::app_context_test_scope();
1291 let mut composition = Composition::new(MemoryApplier::new());
1292 let state = TextFieldState::new("Test content");
1293
1294 let result = composition.render(location_key(file!(), line!(), column!()), {
1295 let state = state.clone();
1296 move || {
1297 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1298 }
1299 });
1300
1301 assert!(result.is_ok());
1302 assert!(composition.root().is_some());
1303 }
1304
1305 #[test]
1306 fn basic_text_field_state_updates() {
1307 let _app_context = crate::render_state::app_context_test_scope();
1308 with_test_runtime(|| {
1309 let state = TextFieldState::new("Hello");
1310 assert_eq!(state.text(), "Hello");
1311
1312 state.edit(|buffer| {
1313 buffer.place_cursor_at_end();
1314 buffer.insert("!");
1315 });
1316
1317 assert_eq!(state.text(), "Hello!");
1318 });
1319 }
1320
1321 #[test]
1330 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1331 use crate::bring_into_view::local_bring_into_view_responder;
1332 use crate::layout::LayoutEngine;
1333 use crate::renderer::HeadlessRenderer;
1334 use crate::widgets::{Box, BoxSpec, PopupHost};
1335 use crate::{LazyColumn, LazyColumnSpec};
1336 use cranpose_core::Key;
1337 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1338 use cranpose_ui_graphics::Size;
1339 use std::cell::RefCell;
1340
1341 let _app_context = crate::render_state::app_context_test_scope();
1342 let mut composition = Composition::new(MemoryApplier::new());
1343 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1344 Rc::new(RefCell::new(None));
1345 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1346
1347 let mut content = {
1350 let responder_slot = Rc::clone(&responder_slot);
1351 let state_slot = Rc::clone(&state_slot);
1352 move || {
1353 let responder_slot = Rc::clone(&responder_slot);
1354 let state_slot = Rc::clone(&state_slot);
1355 PopupHost(move || {
1356 let list_state = remember_lazy_list_state();
1357 *state_slot.borrow_mut() = Some(list_state);
1358 let responder_slot = Rc::clone(&responder_slot);
1359 LazyColumn(
1360 Modifier::empty().size(Size {
1361 width: 300.0,
1362 height: 400.0,
1363 }),
1364 list_state,
1365 LazyColumnSpec::default(),
1366 move |scope| {
1367 let responder_slot = Rc::clone(&responder_slot);
1368 scope.items(
1369 30,
1370 None::<fn(usize) -> u64>,
1371 None::<fn(usize) -> u64>,
1372 move |_index| {
1373 if responder_slot.borrow().is_none() {
1374 if let Some(r) = local_bring_into_view_responder().current()
1375 {
1376 *responder_slot.borrow_mut() = Some(r);
1377 }
1378 }
1379 Box(
1380 Modifier::empty().size(Size {
1381 width: 300.0,
1382 height: 80.0,
1383 }),
1384 BoxSpec::default(),
1385 || {},
1386 );
1387 },
1388 );
1389 },
1390 );
1391 });
1392 }
1393 };
1394
1395 fn run_layout(
1396 composition: &mut Composition<MemoryApplier>,
1397 key: Key,
1398 content: &mut dyn FnMut(),
1399 ) {
1400 for _ in 0..16 {
1401 if !composition.should_render() {
1402 break;
1403 }
1404 composition
1405 .reconcile(key, &mut *content)
1406 .expect("reconcile");
1407 }
1408 let root = composition.root().expect("root");
1409 let handle = composition.runtime_handle();
1410 let mut applier = composition.applier_mut();
1411 applier.set_runtime_handle(handle);
1412 let layout = applier
1413 .compute_layout(
1414 root,
1415 Size {
1416 width: 400.0,
1417 height: 600.0,
1418 },
1419 )
1420 .expect("layout");
1421 applier.clear_runtime_handle();
1422 drop(applier);
1423 let _ = HeadlessRenderer::new().render(&layout);
1424 }
1425
1426 let key = location_key(file!(), line!(), column!());
1427 composition.render(key, &mut content).expect("render");
1428 run_layout(&mut composition, key, &mut content);
1429
1430 let responder = responder_slot
1431 .borrow()
1432 .clone()
1433 .expect("LazyColumn provides a bring-into-view responder to its items");
1434 let list_state = state_slot.borrow().expect("list state captured");
1435 let offset0 = list_state.first_visible_item_scroll_offset();
1436 let index0 = list_state.first_visible_item_index();
1437
1438 responder.bring_into_view(
1441 Rect {
1442 x: 10.0,
1443 y: 100.0,
1444 width: 2.0,
1445 height: 20.0,
1446 },
1447 0.0,
1448 );
1449 run_layout(&mut composition, key, &mut content);
1450 assert_eq!(
1451 list_state.first_visible_item_index(),
1452 index0,
1453 "an already-visible caret must not scroll the list"
1454 );
1455 assert!(
1456 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1457 "an already-visible caret must not scroll the list"
1458 );
1459
1460 responder.bring_into_view(
1463 Rect {
1464 x: 10.0,
1465 y: 360.0,
1466 width: 2.0,
1467 height: 20.0,
1468 },
1469 250.0,
1470 );
1471 run_layout(&mut composition, key, &mut content);
1472 let scrolled_forward = list_state.first_visible_item_index() > index0
1473 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1474 assert!(
1475 scrolled_forward,
1476 "a caret behind the keyboard must scroll the list forward \
1477 (index {} -> {}, offset {:.1} -> {:.1})",
1478 index0,
1479 list_state.first_visible_item_index(),
1480 offset0,
1481 list_state.first_visible_item_scroll_offset(),
1482 );
1483 }
1484}