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::get_offset_for_position(&AnnotatedString::from(text), style, local_x, local_y)
77}
78#[composable]
96pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
97 BasicTextFieldWithOptions(
98 state,
99 modifier,
100 BasicTextFieldOptions {
101 text_style: style,
102 ..BasicTextFieldOptions::default()
103 },
104 )
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub struct BasicTextFieldOptions {
110 pub text_style: TextStyle,
112 pub cursor_color: Color,
114 pub line_limits: TextFieldLineLimits,
116}
117
118impl Default for BasicTextFieldOptions {
119 fn default() -> Self {
120 Self {
121 text_style: TextStyle::default(),
122 cursor_color: Color(0.0, 0.0, 0.0, 1.0), line_limits: TextFieldLineLimits::default(),
124 }
125 }
126}
127
128#[composable]
132pub fn BasicTextFieldWithOptions(
133 state: TextFieldState,
134 modifier: Modifier,
135 options: BasicTextFieldOptions,
136) -> NodeId {
137 let _text = state.text();
140 let _selection = state.selection();
141
142 let controller =
146 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
147
148 let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
150 .with_cursor_color(options.cursor_color)
151 .with_line_limits(options.line_limits)
152 .with_handle_controller(controller.clone());
153
154 let text_field_modifier = modifier_element(text_field_element);
156 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
157 let combined_modifier = modifier.then(final_modifier);
158
159 let node = Layout(
162 combined_modifier,
163 EmptyMeasurePolicy,
164 || {}, );
166
167 BringCaretIntoView(
172 state.clone(),
173 options.text_style.clone(),
174 controller.clone(),
175 );
176
177 SelectionHandles(state, options.text_style, controller);
183
184 node
185}
186
187fn caret_window_rect(
191 text: &str,
192 style: &TextStyle,
193 metrics: &TextFieldHandleMetrics,
194 offset: usize,
195) -> Rect {
196 let tip = handle_tip_window_pos(text, style, metrics, offset);
199 Rect {
200 x: tip.x,
201 y: tip.y - metrics.line_height,
202 width: 2.0,
203 height: metrics.line_height,
204 }
205}
206
207#[composable]
217fn BringCaretIntoView(
218 state: TextFieldState,
219 style: TextStyle,
220 controller: TextFieldHandleController,
221) {
222 let Some(metrics) = controller.metrics() else {
223 return;
224 };
225 let ime_bottom = local_ime_insets().current().bottom;
228 let responder = local_bring_into_view_responder().current();
229
230 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
231 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
232
233 if !metrics.focused {
234 previous.set(None);
237 return;
238 }
239 let Some(responder) = responder else {
240 return;
241 };
242
243 let text = state.text();
244 let selection = state.selection();
245 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
246
247 let key = (
251 selection.start,
252 selection.end,
253 (ime_bottom * 4.0).round() as i64,
254 );
255 SideEffect(move || {
256 if previous.get() != Some(key) {
257 previous.set(Some(key));
258 responder.bring_into_view(caret, ime_bottom);
259 }
260 });
261}
262
263#[composable]
266fn SelectionHandles(
267 state: TextFieldState,
268 style: TextStyle,
269 controller: TextFieldHandleController,
270) {
271 let selection = state.selection();
272 let current_range = (selection.min(), selection.max());
273
274 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
278 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
283 let caret_menu_offset: Rc<Cell<usize>> =
286 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
287 let previous_range: Rc<Cell<(usize, usize)>> =
288 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
289 {
290 let previous_range = Rc::clone(&previous_range);
291 SideEffect(move || {
292 if previous_range.get() != current_range {
293 previous_range.set(current_range);
294 menu_open.set(true);
295 }
296 });
297 }
298 {
301 let caret_menu_offset = Rc::clone(&caret_menu_offset);
302 let caret_start = selection.start;
303 SideEffect(move || {
304 if caret_menu_open.value()
305 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
306 {
307 caret_menu_open.set(false);
308 }
309 });
310 }
311
312 let Some(metrics) = controller.metrics() else {
313 return;
314 };
315 if !metrics.focused || !metrics.touch {
316 return;
317 }
318
319 let text = state.text();
320
321 let drag_pos: MutableState<Option<Point>> =
325 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
326
327 if selection.collapsed() {
328 let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
330 let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
331 let open_caret_menu = {
334 let caret_menu_offset = Rc::clone(&caret_menu_offset);
335 let state = state.clone();
336 move || {
337 caret_menu_offset.set(state.selection().start);
338 caret_menu_open.set(true);
339 }
340 };
341 let on_tap = open_caret_menu.clone();
342 let on_long_press = open_caret_menu;
343 SelectionHandle(
344 HandleKind::Cursor,
345 tip,
346 HANDLE_RADIUS,
347 HANDLE_COLOR,
348 move |pos| {
349 drag_pos.set(Some(pos));
350 on_drag(pos);
351 },
352 move || drag_pos.set(None),
353 on_long_press,
354 on_tap,
355 );
356
357 if caret_menu_open.value() {
360 let caret_anchor = Point {
361 x: tip.x,
362 y: tip.y - metrics.line_height,
363 };
364 let can_paste = clipboard_read_text().is_some();
365 let can_undo = state.can_undo();
366 let can_redo = state.can_redo();
367 let undo_state = state.clone();
368 let redo_state = state.clone();
369 CaretActionMenu(
370 caret_anchor,
371 can_paste,
372 can_undo,
373 can_redo,
374 move || {
375 if let Some(text) = clipboard_read_text() {
376 dispatch_paste(&text);
377 }
378 caret_menu_open.set(false);
379 },
380 move || {
381 dispatch_select_all();
382 caret_menu_open.set(false);
383 },
384 move || {
385 undo_state.undo();
386 crate::request_render_invalidation();
387 caret_menu_open.set(false);
388 },
389 move || {
390 redo_state.redo();
391 crate::request_render_invalidation();
392 caret_menu_open.set(false);
393 },
394 );
395 }
396 } else {
397 let start = selection.min();
399 let end = selection.max();
400 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
401 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
402
403 let on_drag_start = drag_edge_closure(
404 HandleKind::SelectionStart,
405 state.clone(),
406 style.clone(),
407 controller.clone(),
408 );
409 SelectionHandle(
410 HandleKind::SelectionStart,
411 start_tip,
412 HANDLE_RADIUS,
413 HANDLE_COLOR,
414 move |pos| {
415 drag_pos.set(Some(pos));
416 on_drag_start(pos);
417 },
418 move || drag_pos.set(None),
419 move || menu_open.set(true),
423 move || menu_open.set(true),
425 );
426
427 let on_drag_end = drag_edge_closure(
428 HandleKind::SelectionEnd,
429 state.clone(),
430 style.clone(),
431 controller.clone(),
432 );
433 SelectionHandle(
434 HandleKind::SelectionEnd,
435 end_tip,
436 HANDLE_RADIUS,
437 HANDLE_COLOR,
438 move |pos| {
439 drag_pos.set(Some(pos));
440 on_drag_end(pos);
441 },
442 move || drag_pos.set(None),
443 move || menu_open.set(true),
444 move || menu_open.set(true),
445 );
446
447 if menu_open.value() {
450 let menu_anchor = Point {
452 x: (start_tip.x + end_tip.x) * 0.5,
453 y: start_tip.y - metrics.line_height,
454 };
455 let can_paste = clipboard_read_text().is_some();
456 TextSelectionMenu(
457 menu_anchor,
458 can_paste,
459 move || {
460 if let Some(text) = dispatch_copy() {
461 clipboard_write_text(&text);
462 }
463 menu_open.set(false);
464 },
465 move || {
466 if let Some(text) = dispatch_cut() {
467 clipboard_write_text(&text);
468 }
469 menu_open.set(false);
470 },
471 move || {
472 if let Some(text) = clipboard_read_text() {
473 dispatch_paste(&text);
474 }
475 menu_open.set(false);
476 },
477 move || {
478 dispatch_select_all();
479 menu_open.set(false);
480 },
481 );
482 }
483 }
484
485 if let Some(finger) = drag_pos.value() {
492 let offset = window_pos_to_offset(&text, &style, &metrics, finger);
493 CursorMagnifier(text.clone(), style.clone(), metrics, offset, finger);
494 }
495}
496
497fn drag_caret_closure(
500 state: TextFieldState,
501 style: TextStyle,
502 controller: TextFieldHandleController,
503) -> Rc<dyn Fn(Point)> {
504 Rc::new(move |window_pos: Point| {
505 let Some(metrics) = controller.metrics() else {
506 return;
507 };
508 let text = state.text();
509 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
510 state.set_selection(TextRange::new(offset, offset));
511 crate::request_render_invalidation();
512 })
513}
514
515fn drag_edge_closure(
519 dragged: HandleKind,
520 state: TextFieldState,
521 style: TextStyle,
522 controller: TextFieldHandleController,
523) -> Rc<dyn Fn(Point)> {
524 Rc::new(move |window_pos: Point| {
525 let Some(metrics) = controller.metrics() else {
526 return;
527 };
528 let text = state.text();
529 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
530 let selection = state.selection();
531 let fixed_edge = match dragged {
532 HandleKind::SelectionStart => selection.max(),
533 _ => selection.min(),
534 };
535 let (min, max) =
536 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
537 state.set_selection(TextRange::new(min, max));
538 crate::request_render_invalidation();
539 })
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
546 use std::sync::Arc;
547
548 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
550 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
551 f()
552 }
553
554 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
558 use crate::layout::LayoutEngine;
559 use crate::renderer::HeadlessRenderer;
560 use crate::widgets::PopupHost;
561 use cranpose_ui_graphics::Size;
562
563 let mut composition = Composition::new(MemoryApplier::new());
564 let key = location_key(file!(), line!(), column!());
565 let state = TextFieldState::new("hello world");
566
567 let mut content = {
568 let state = state.clone();
569 move || {
570 let state = state.clone();
571 PopupHost(move || {
572 let controller = TextFieldHandleController::new();
573 controller.publish(TextFieldHandleMetrics {
574 focused: true,
575 touch,
576 node_origin: Point { x: 0.0, y: 10.0 },
577 padding_left: 0.0,
578 padding_top: 0.0,
579 scroll_offset: 0.0,
580 line_height: 18.0,
581 wrap_width: None,
582 });
583 SelectionHandles(state.clone(), TextStyle::default(), controller);
584 });
585 }
586 };
587
588 composition.render(key, &mut content).expect("render");
589 for _ in 0..16 {
590 if !composition.should_render() {
591 break;
592 }
593 composition.reconcile(key, &mut content).expect("reconcile");
594 }
595 let root = composition.root().expect("root");
596 let handle = composition.runtime_handle();
597 let mut applier = composition.applier_mut();
598 applier.set_runtime_handle(handle);
599 let layout = applier
600 .compute_layout(
601 root,
602 Size {
603 width: 400.0,
604 height: 400.0,
605 },
606 )
607 .expect("layout");
608 applier.clear_runtime_handle();
609 drop(applier);
610 HeadlessRenderer::new().render(&layout)
611 }
612
613 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
616 use crate::layout::LayoutEngine;
617 use crate::renderer::HeadlessRenderer;
618 use crate::widgets::PopupHost;
619 use cranpose_ui_graphics::Size;
620
621 let mut composition = Composition::new(MemoryApplier::new());
622 let key = location_key(file!(), line!(), column!());
623 let state = TextFieldState::new("hello world");
624
625 let mut content = {
626 let state = state.clone();
627 move || {
628 let state = state.clone();
629 PopupHost(move || {
630 let controller = TextFieldHandleController::new();
631 if state.selection() != TextRange::new(0, 5) {
632 state.set_selection(TextRange::new(0, 5));
633 }
634 controller.publish(TextFieldHandleMetrics {
635 focused: true,
636 touch,
637 node_origin: Point { x: 0.0, y: 40.0 },
638 padding_left: 0.0,
639 padding_top: 0.0,
640 scroll_offset: 0.0,
641 line_height: 18.0,
642 wrap_width: None,
643 });
644 SelectionHandles(state.clone(), TextStyle::default(), controller);
645 });
646 }
647 };
648
649 composition.render(key, &mut content).expect("render");
650 for _ in 0..16 {
651 if !composition.should_render() {
652 break;
653 }
654 composition.reconcile(key, &mut content).expect("reconcile");
655 }
656 let root = composition.root().expect("root");
657 let handle = composition.runtime_handle();
658 let mut applier = composition.applier_mut();
659 applier.set_runtime_handle(handle);
660 let layout = applier
661 .compute_layout(
662 root,
663 Size {
664 width: 400.0,
665 height: 400.0,
666 },
667 )
668 .expect("layout");
669 applier.clear_runtime_handle();
670 drop(applier);
671 HeadlessRenderer::new().render(&layout)
672 }
673
674 fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
680 use crate::layout::LayoutEngine;
681 use crate::renderer::HeadlessRenderer;
682 use crate::widgets::{BoxWithConstraints, PopupHost};
683 use cranpose_ui_graphics::Size;
684
685 let mut composition = Composition::new(MemoryApplier::new());
686 let key = location_key(file!(), line!(), column!());
687 let state = TextFieldState::new("hello world");
688
689 let mut content = {
690 let state = state.clone();
691 move || {
692 let state = state.clone();
693 PopupHost(move || {
694 let state = state.clone();
695 BoxWithConstraints(
696 Modifier::empty().size(Size {
697 width: 300.0,
698 height: 300.0,
699 }),
700 move |_scope| {
701 let controller = TextFieldHandleController::new();
702 if state.selection() != TextRange::new(0, 5) {
703 state.set_selection(TextRange::new(0, 5));
704 }
705 controller.publish(TextFieldHandleMetrics {
706 focused: true,
707 touch,
708 node_origin: Point { x: 0.0, y: 40.0 },
709 padding_left: 0.0,
710 padding_top: 0.0,
711 scroll_offset: 0.0,
712 line_height: 18.0,
713 wrap_width: None,
714 });
715 SelectionHandles(state.clone(), TextStyle::default(), controller);
716 },
717 );
718 });
719 }
720 };
721
722 composition.render(key, &mut content).expect("render");
723 let root = composition.root().expect("root");
724 let handle = composition.runtime_handle();
725 let mut scene = None;
726 for _ in 0..8 {
730 for _ in 0..16 {
731 if !composition.should_render() {
732 break;
733 }
734 composition.reconcile(key, &mut content).expect("reconcile");
735 }
736 let mut applier = composition.applier_mut();
737 applier.set_runtime_handle(handle.clone());
738 let layout = applier
739 .compute_layout(
740 root,
741 Size {
742 width: 400.0,
743 height: 400.0,
744 },
745 )
746 .expect("layout");
747 applier.clear_runtime_handle();
748 drop(applier);
749 scene = Some(HeadlessRenderer::new().render(&layout));
750 }
751 scene.expect("scene")
752 }
753
754 fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
761 use crate::layout::LayoutEngine;
762 use crate::renderer::HeadlessRenderer;
763 use crate::widgets::PopupHost;
764 use crate::{LazyColumn, LazyColumnSpec};
765 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
766 use cranpose_ui_graphics::Size;
767
768 let mut composition = Composition::new(MemoryApplier::new());
769 let key = location_key(file!(), line!(), column!());
770 let state = TextFieldState::new("hello world");
771
772 let mut content = {
773 let state = state.clone();
774 move || {
775 let state = state.clone();
776 PopupHost(move || {
777 let state = state.clone();
778 let list_state = remember_lazy_list_state();
779 LazyColumn(
780 Modifier::empty().size(Size {
781 width: 300.0,
782 height: 300.0,
783 }),
784 list_state,
785 LazyColumnSpec::default(),
786 move |scope| {
787 let state = state.clone();
788 scope.items(
789 1,
790 None::<fn(usize) -> u64>,
791 None::<fn(usize) -> u64>,
792 move |_index| {
793 let controller = TextFieldHandleController::new();
794 if state.selection() != TextRange::new(0, 5) {
795 state.set_selection(TextRange::new(0, 5));
796 }
797 controller.publish(TextFieldHandleMetrics {
798 focused: true,
799 touch,
800 node_origin: Point { x: 0.0, y: 40.0 },
801 padding_left: 0.0,
802 padding_top: 0.0,
803 scroll_offset: 0.0,
804 line_height: 18.0,
805 wrap_width: None,
806 });
807 SelectionHandles(
808 state.clone(),
809 TextStyle::default(),
810 controller,
811 );
812 },
813 );
814 },
815 );
816 });
817 }
818 };
819
820 composition.render(key, &mut content).expect("render");
821 let root = composition.root().expect("root");
822 let handle = composition.runtime_handle();
823 let mut scene = None;
824 for _ in 0..8 {
825 for _ in 0..16 {
826 if !composition.should_render() {
827 break;
828 }
829 composition.reconcile(key, &mut content).expect("reconcile");
830 }
831 let mut applier = composition.applier_mut();
832 applier.set_runtime_handle(handle.clone());
833 let layout = applier
834 .compute_layout(
835 root,
836 Size {
837 width: 400.0,
838 height: 400.0,
839 },
840 )
841 .expect("layout");
842 applier.clear_runtime_handle();
843 drop(applier);
844 scene = Some(HeadlessRenderer::new().render(&layout));
845 }
846 scene.expect("scene")
847 }
848
849 #[test]
858 fn field_window_origin_follows_vertical_scroll() {
859 use crate::layout::policies::EmptyMeasurePolicy;
860 use crate::layout::{LayoutBox, LayoutEngine};
861 use crate::renderer::HeadlessRenderer;
862 use crate::scroll::ScrollState;
863 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
864 use cranpose_core::{remember, Key};
865 use cranpose_foundation::modifier_element;
866 use cranpose_ui_graphics::Size;
867 use std::cell::RefCell;
868
869 let _app_context = crate::render_state::app_context_test_scope();
870
871 let mut composition = Composition::new(MemoryApplier::new());
874 let state = TextFieldState::new("hello world");
875 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
876 Rc::new(RefCell::new(None));
877 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
880
881 let spacer_before = 200.0_f32;
882 let mut content = {
883 let state = state.clone();
884 let controller_slot = Rc::clone(&controller_slot);
885 let scroll_slot = Rc::clone(&scroll_slot);
886 move || {
887 let state = state.clone();
888 let controller_slot = Rc::clone(&controller_slot);
889 let scroll_slot = Rc::clone(&scroll_slot);
890 PopupHost(move || {
891 let controller = remember(TextFieldHandleController::new)
892 .with(TextFieldHandleController::clone);
893 *controller_slot.borrow_mut() = Some(controller.clone());
894 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
895 *scroll_slot.borrow_mut() = Some(scroll.clone());
896 let state = state.clone();
897 let controller = controller.clone();
898 Column(
899 Modifier::empty()
900 .size(Size {
901 width: 300.0,
902 height: 150.0,
903 })
904 .vertical_scroll(scroll.clone(), false),
905 ColumnSpec::default(),
906 move || {
907 Spacer(Size {
908 width: 300.0,
909 height: spacer_before,
910 });
911 let element =
912 TextFieldElement::new(state.clone(), TextStyle::default())
913 .with_handle_controller(controller.clone());
914 let field_modifier =
915 Modifier::from_parts(vec![modifier_element(element)]);
916 Layout(field_modifier, EmptyMeasurePolicy, || {});
917 Spacer(Size {
918 width: 300.0,
919 height: 400.0,
920 });
921 },
922 );
923 });
924 }
925 };
926
927 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
929 if node
930 .node_data
931 .modifier_slices()
932 .text_field_window_origin()
933 .is_some()
934 {
935 return Some(node.rect);
936 }
937 node.children.iter().find_map(find_field_rect)
938 }
939
940 fn layout_and_read(
941 composition: &mut Composition<MemoryApplier>,
942 key: Key,
943 content: &mut dyn FnMut(),
944 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
945 ) -> (Point, f32) {
946 for _ in 0..16 {
947 if !composition.should_render() {
948 break;
949 }
950 composition
951 .reconcile(key, &mut *content)
952 .expect("reconcile");
953 }
954 let root = composition.root().expect("root");
955 let handle = composition.runtime_handle();
956 let mut applier = composition.applier_mut();
957 applier.set_runtime_handle(handle);
958 let layout = applier
959 .compute_layout(
960 root,
961 cranpose_ui_graphics::Size {
962 width: 400.0,
963 height: 600.0,
964 },
965 )
966 .expect("layout");
967 applier.clear_runtime_handle();
968 drop(applier);
969 let _ = HeadlessRenderer::new().render(&layout);
970 let field_y = find_field_rect(layout.root()).expect("field placed").y;
971 let node_origin = controller_slot
972 .borrow()
973 .as_ref()
974 .expect("controller")
975 .metrics()
976 .expect("metrics published")
977 .node_origin;
978 (node_origin, field_y)
979 }
980
981 let key = location_key(file!(), line!(), column!());
982 composition.render(key, &mut content).expect("render");
983
984 let (origin0, field_y0) =
985 layout_and_read(&mut composition, key, &mut content, &controller_slot);
986 assert!(
989 (origin0.y - field_y0).abs() < 0.5,
990 "published node_origin.y {} must equal the field's placed window-y {}",
991 origin0.y,
992 field_y0
993 );
994 assert!(
995 origin0.y >= spacer_before - 0.5,
996 "field should start at/after the {spacer_before}px leading spacer, got {}",
997 origin0.y
998 );
999
1000 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
1003 scroll.scroll_to(50.0);
1004 assert!(
1005 scroll.value() >= 49.5,
1006 "test setup: content must be tall enough to scroll 50px (got {})",
1007 scroll.value()
1008 );
1009 let (origin1, field_y1) =
1010 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1011 assert!(
1012 (origin1.y - field_y1).abs() < 0.5,
1013 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1014 origin1.y,
1015 field_y1
1016 );
1017 assert!(
1018 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1019 "scrolling 50px must shift the published field origin up by 50px: \
1020 before {}, after {} (expected {})",
1021 origin0.y,
1022 origin1.y,
1023 origin0.y - 50.0
1024 );
1025 }
1026
1027 #[test]
1033 fn window_offset_roundtrip_holds_under_scroll_offset() {
1034 let _app_context = crate::render_state::app_context_test_scope();
1035 let text = "hello world";
1036 let style = TextStyle::default();
1037 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1039 let metrics = TextFieldHandleMetrics {
1040 focused: true,
1041 touch: true,
1042 node_origin,
1043 padding_left: 4.0,
1044 padding_top: 3.0,
1045 scroll_offset: 0.0,
1046 line_height: 18.0,
1047 wrap_width: None,
1048 };
1049 for offset in 0..=text.len() {
1050 if !text.is_char_boundary(offset) {
1051 continue;
1052 }
1053 let tip = handle_tip_window_pos(text, &style, &metrics, offset);
1054 let resolved = window_pos_to_offset(text, &style, &metrics, tip);
1055 assert_eq!(
1056 resolved, offset,
1057 "finger at the tip of offset {offset} must map back to it \
1058 under origin {node_origin:?}, got {resolved}"
1059 );
1060 }
1061 }
1062 }
1063
1064 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1065 use crate::renderer::RenderOp;
1066 scene
1067 .operations()
1068 .iter()
1069 .filter_map(|op| match op {
1070 RenderOp::Text { value, .. } => Some(value.clone()),
1071 _ => None,
1072 })
1073 .collect()
1074 }
1075
1076 fn render_caret_action_menu(
1080 can_paste: bool,
1081 can_undo: bool,
1082 can_redo: bool,
1083 ) -> crate::renderer::RecordedRenderScene {
1084 use crate::layout::LayoutEngine;
1085 use crate::renderer::HeadlessRenderer;
1086 use crate::widgets::PopupHost;
1087 use cranpose_ui_graphics::Size;
1088
1089 let mut composition = Composition::new(MemoryApplier::new());
1090 let key = location_key(file!(), line!(), column!());
1091
1092 let mut content = move || {
1093 PopupHost(move || {
1094 CaretActionMenu(
1095 Point { x: 40.0, y: 60.0 },
1096 can_paste,
1097 can_undo,
1098 can_redo,
1099 || {},
1100 || {},
1101 || {},
1102 || {},
1103 );
1104 });
1105 };
1106
1107 composition.render(key, &mut content).expect("render");
1108 for _ in 0..16 {
1109 if !composition.should_render() {
1110 break;
1111 }
1112 composition.reconcile(key, &mut content).expect("reconcile");
1113 }
1114 let root = composition.root().expect("root");
1115 let handle = composition.runtime_handle();
1116 let mut applier = composition.applier_mut();
1117 applier.set_runtime_handle(handle);
1118 let layout = applier
1119 .compute_layout(
1120 root,
1121 Size {
1122 width: 400.0,
1123 height: 400.0,
1124 },
1125 )
1126 .expect("layout");
1127 applier.clear_runtime_handle();
1128 drop(applier);
1129 HeadlessRenderer::new().render(&layout)
1130 }
1131
1132 #[test]
1136 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1137 let _app_context = crate::render_state::app_context_test_scope();
1138
1139 let all = text_values(&render_caret_action_menu(true, true, true));
1140 for label in ["Paste", "Select all", "Undo", "Redo"] {
1141 assert!(
1142 all.iter().any(|t| t == label),
1143 "caret menu should show {label:?}, got {all:?}"
1144 );
1145 }
1146
1147 let bare = text_values(&render_caret_action_menu(false, false, false));
1149 assert!(
1150 bare.iter().any(|t| t == "Select all"),
1151 "Select all is always available, got {bare:?}"
1152 );
1153 assert!(
1154 !bare
1155 .iter()
1156 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1157 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1158 );
1159 }
1160
1161 #[test]
1162 fn context_menu_shows_for_touch_selection_only() {
1163 let _app_context = crate::render_state::app_context_test_scope();
1164
1165 let touch = text_values(&render_range_menu(true));
1166 assert!(
1167 touch.iter().any(|t| t == "Copy"),
1168 "touch selection should show the Copy menu item, got {touch:?}"
1169 );
1170 assert!(
1171 touch.iter().any(|t| t == "Cut"),
1172 "expected Cut, got {touch:?}"
1173 );
1174 assert!(
1175 touch.iter().any(|t| t == "Select all"),
1176 "expected Select all, got {touch:?}"
1177 );
1178
1179 let mouse = text_values(&render_range_menu(false));
1180 assert!(
1181 !mouse.iter().any(|t| t == "Copy"),
1182 "mouse selection must not show the finger contextual menu, got {mouse:?}"
1183 );
1184 }
1185
1186 #[test]
1187 fn selection_handles_and_menu_survive_subcomposition() {
1188 let _app_context = crate::render_state::app_context_test_scope();
1194 let scene = render_range_menu_subcomposed(true);
1195
1196 let texts = text_values(&scene);
1197 assert!(
1198 texts.iter().any(|t| t == "Copy"),
1199 "a touch selection inside a subcomposition should show the Copy menu \
1200 item through the host, got {texts:?}"
1201 );
1202 assert!(
1203 texts.iter().any(|t| t == "Select all"),
1204 "expected Select all inside a subcomposition, got {texts:?}"
1205 );
1206 assert_eq!(
1207 image_count(&scene),
1208 2,
1209 "a touch range selection should show two finger teardrop handles in \
1210 the overlay across the subcomposition boundary"
1211 );
1212 }
1213
1214 #[test]
1215 fn selection_handles_and_menu_survive_lazy_column_item() {
1216 let _app_context = crate::render_state::app_context_test_scope();
1223 let scene = render_range_menu_lazy_column(true);
1224
1225 let texts = text_values(&scene);
1226 assert!(
1227 texts.iter().any(|t| t == "Copy"),
1228 "a touch selection inside a LazyColumn item should show the Copy menu \
1229 item through the host, got {texts:?}"
1230 );
1231 assert!(
1232 texts.iter().any(|t| t == "Select all"),
1233 "expected Select all inside a LazyColumn item, got {texts:?}"
1234 );
1235 assert_eq!(
1236 image_count(&scene),
1237 2,
1238 "a touch range selection should show two finger teardrop handles in \
1239 the overlay across the LazyColumn item subcomposition boundary"
1240 );
1241 }
1242
1243 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1244 use crate::renderer::RenderOp;
1245 use cranpose_ui_graphics::DrawPrimitive;
1246 scene
1247 .operations()
1248 .iter()
1249 .filter(|op| {
1250 matches!(
1251 op,
1252 RenderOp::Primitive {
1253 primitive: DrawPrimitive::Image { .. },
1254 ..
1255 }
1256 )
1257 })
1258 .count()
1259 }
1260
1261 #[test]
1262 fn cursor_handle_shows_for_touch_only() {
1263 let _app_context = crate::render_state::app_context_test_scope();
1264 assert_eq!(
1265 image_count(&render_collapsed_handles(true)),
1266 1,
1267 "a touch caret should show one finger cursor handle in the overlay"
1268 );
1269 assert_eq!(
1270 image_count(&render_collapsed_handles(false)),
1271 0,
1272 "a mouse caret should keep a clean caret with no finger handle"
1273 );
1274 }
1275
1276 #[test]
1277 fn basic_text_field_creates_node() {
1278 let _app_context = crate::render_state::app_context_test_scope();
1279 let mut composition = Composition::new(MemoryApplier::new());
1280 let state = TextFieldState::new("Test content");
1281
1282 let result = composition.render(location_key(file!(), line!(), column!()), {
1283 let state = state.clone();
1284 move || {
1285 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1286 }
1287 });
1288
1289 assert!(result.is_ok());
1290 assert!(composition.root().is_some());
1291 }
1292
1293 #[test]
1294 fn basic_text_field_state_updates() {
1295 let _app_context = crate::render_state::app_context_test_scope();
1296 with_test_runtime(|| {
1297 let state = TextFieldState::new("Hello");
1298 assert_eq!(state.text(), "Hello");
1299
1300 state.edit(|buffer| {
1301 buffer.place_cursor_at_end();
1302 buffer.insert("!");
1303 });
1304
1305 assert_eq!(state.text(), "Hello!");
1306 });
1307 }
1308
1309 #[test]
1318 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1319 use crate::bring_into_view::local_bring_into_view_responder;
1320 use crate::layout::LayoutEngine;
1321 use crate::renderer::HeadlessRenderer;
1322 use crate::widgets::{Box, BoxSpec, PopupHost};
1323 use crate::{LazyColumn, LazyColumnSpec};
1324 use cranpose_core::Key;
1325 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1326 use cranpose_ui_graphics::Size;
1327 use std::cell::RefCell;
1328
1329 let _app_context = crate::render_state::app_context_test_scope();
1330 let mut composition = Composition::new(MemoryApplier::new());
1331 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1332 Rc::new(RefCell::new(None));
1333 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1334
1335 let mut content = {
1338 let responder_slot = Rc::clone(&responder_slot);
1339 let state_slot = Rc::clone(&state_slot);
1340 move || {
1341 let responder_slot = Rc::clone(&responder_slot);
1342 let state_slot = Rc::clone(&state_slot);
1343 PopupHost(move || {
1344 let list_state = remember_lazy_list_state();
1345 *state_slot.borrow_mut() = Some(list_state);
1346 let responder_slot = Rc::clone(&responder_slot);
1347 LazyColumn(
1348 Modifier::empty().size(Size {
1349 width: 300.0,
1350 height: 400.0,
1351 }),
1352 list_state,
1353 LazyColumnSpec::default(),
1354 move |scope| {
1355 let responder_slot = Rc::clone(&responder_slot);
1356 scope.items(
1357 30,
1358 None::<fn(usize) -> u64>,
1359 None::<fn(usize) -> u64>,
1360 move |_index| {
1361 if responder_slot.borrow().is_none() {
1362 if let Some(r) = local_bring_into_view_responder().current()
1363 {
1364 *responder_slot.borrow_mut() = Some(r);
1365 }
1366 }
1367 Box(
1368 Modifier::empty().size(Size {
1369 width: 300.0,
1370 height: 80.0,
1371 }),
1372 BoxSpec::default(),
1373 || {},
1374 );
1375 },
1376 );
1377 },
1378 );
1379 });
1380 }
1381 };
1382
1383 fn run_layout(
1384 composition: &mut Composition<MemoryApplier>,
1385 key: Key,
1386 content: &mut dyn FnMut(),
1387 ) {
1388 for _ in 0..16 {
1389 if !composition.should_render() {
1390 break;
1391 }
1392 composition
1393 .reconcile(key, &mut *content)
1394 .expect("reconcile");
1395 }
1396 let root = composition.root().expect("root");
1397 let handle = composition.runtime_handle();
1398 let mut applier = composition.applier_mut();
1399 applier.set_runtime_handle(handle);
1400 let layout = applier
1401 .compute_layout(
1402 root,
1403 Size {
1404 width: 400.0,
1405 height: 600.0,
1406 },
1407 )
1408 .expect("layout");
1409 applier.clear_runtime_handle();
1410 drop(applier);
1411 let _ = HeadlessRenderer::new().render(&layout);
1412 }
1413
1414 let key = location_key(file!(), line!(), column!());
1415 composition.render(key, &mut content).expect("render");
1416 run_layout(&mut composition, key, &mut content);
1417
1418 let responder = responder_slot
1419 .borrow()
1420 .clone()
1421 .expect("LazyColumn provides a bring-into-view responder to its items");
1422 let list_state = state_slot.borrow().expect("list state captured");
1423 let offset0 = list_state.first_visible_item_scroll_offset();
1424 let index0 = list_state.first_visible_item_index();
1425
1426 responder.bring_into_view(
1429 Rect {
1430 x: 10.0,
1431 y: 100.0,
1432 width: 2.0,
1433 height: 20.0,
1434 },
1435 0.0,
1436 );
1437 run_layout(&mut composition, key, &mut content);
1438 assert_eq!(
1439 list_state.first_visible_item_index(),
1440 index0,
1441 "an already-visible caret must not scroll the list"
1442 );
1443 assert!(
1444 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1445 "an already-visible caret must not scroll the list"
1446 );
1447
1448 responder.bring_into_view(
1451 Rect {
1452 x: 10.0,
1453 y: 360.0,
1454 width: 2.0,
1455 height: 20.0,
1456 },
1457 250.0,
1458 );
1459 run_layout(&mut composition, key, &mut content);
1460 let scrolled_forward = list_state.first_visible_item_index() > index0
1461 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1462 assert!(
1463 scrolled_forward,
1464 "a caret behind the keyboard must scroll the list forward \
1465 (index {} -> {}, offset {:.1} -> {:.1})",
1466 index0,
1467 list_state.first_visible_item_index(),
1468 offset0,
1469 list_state.first_visible_item_scroll_offset(),
1470 );
1471 }
1472}