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 loupe_target_for_drag, CaretActionMenu, Layout, SelectionHandle, SelectionLoupe,
22 TextSelectionMenu,
23};
24use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
25use cranpose_foundation::modifier_element;
26use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
27use cranpose_ui_graphics::{Color, Point, Rect};
28use std::cell::Cell;
29use std::rc::Rc;
30
31pub const SELECTION_HIGHLIGHT_ALPHA: f32 = 0.32;
36
37fn handle_tip_window_pos(
40 text: &str,
41 style: &TextStyle,
42 metrics: &TextFieldHandleMetrics,
43 offset: usize,
44) -> Point {
45 let offset = offset.min(text.len());
46 let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
50 text,
51 style,
52 None,
53 metrics.wrap_width,
54 offset,
55 );
56 let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
57 Point {
58 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
59 y: metrics.node_origin.y
62 + metrics.padding_top
63 + line_index as f32 * metrics.line_height
64 + metrics.glyph_box.0
65 + metrics.glyph_box.1,
66 }
67}
68
69fn window_pos_to_offset(
76 text: &str,
77 style: &TextStyle,
78 metrics: &TextFieldHandleMetrics,
79 window_pos: Point,
80 y_bias: f32,
81) -> usize {
82 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
83 + metrics.scroll_offset)
84 .max(0.0);
85 let local_y = (window_pos.y + y_bias
88 - 0.5 * metrics.line_height
89 - metrics.node_origin.y
90 - metrics.padding_top)
91 .max(0.0);
92 crate::text::offset_for_position_wrapped(
97 text,
98 style,
99 None,
100 metrics.wrap_width,
101 metrics.line_height,
102 local_x,
103 local_y,
104 )
105}
106#[composable]
124pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
125 BasicTextFieldWithOptions(
126 state,
127 modifier,
128 BasicTextFieldOptions {
129 text_style: style,
130 ..BasicTextFieldOptions::default()
131 },
132 )
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct BasicTextFieldOptions {
138 pub text_style: TextStyle,
140 pub cursor_color: Color,
142 pub line_limits: TextFieldLineLimits,
144}
145
146impl Default for BasicTextFieldOptions {
147 fn default() -> Self {
148 Self {
149 text_style: TextStyle::default(),
150 cursor_color: Color(0.0, 0.478, 1.0, 1.0),
153 line_limits: TextFieldLineLimits::default(),
154 }
155 }
156}
157
158#[composable]
162pub fn BasicTextFieldWithOptions(
163 state: TextFieldState,
164 modifier: Modifier,
165 options: BasicTextFieldOptions,
166) -> NodeId {
167 let _text = state.text();
170 let _selection = state.selection();
171
172 let controller =
176 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
177
178 let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
180 .with_cursor_color(options.cursor_color)
181 .with_line_limits(options.line_limits)
182 .with_handle_controller(controller.clone());
183
184 let text_field_modifier = modifier_element(text_field_element);
186 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
187 let combined_modifier = modifier.then(final_modifier);
188
189 let node = Layout(
192 combined_modifier,
193 EmptyMeasurePolicy,
194 || {}, );
196
197 BringCaretIntoView(
202 state.clone(),
203 options.text_style.clone(),
204 controller.clone(),
205 );
206
207 SelectionHandles(state, options.text_style, controller, options.cursor_color);
213
214 node
215}
216
217fn caret_window_rect(
221 text: &str,
222 style: &TextStyle,
223 metrics: &TextFieldHandleMetrics,
224 offset: usize,
225) -> Rect {
226 let tip = handle_tip_window_pos(text, style, metrics, offset);
229 Rect {
230 x: tip.x,
231 y: tip.y - metrics.glyph_box.1,
232 width: 2.0,
233 height: metrics.glyph_box.1,
234 }
235}
236
237#[composable]
247fn BringCaretIntoView(
248 state: TextFieldState,
249 style: TextStyle,
250 controller: TextFieldHandleController,
251) {
252 let Some(metrics) = controller.metrics() else {
253 return;
254 };
255 let ime_bottom = local_ime_insets().current().bottom;
258 let responder = local_bring_into_view_responder().current();
259
260 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
261 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
262
263 if !metrics.focused {
264 previous.set(None);
267 return;
268 }
269 let Some(responder) = responder else {
270 return;
271 };
272
273 let text = state.text();
274 let selection = state.selection();
275 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
276
277 let key = (
281 selection.start,
282 selection.end,
283 (ime_bottom * 4.0).round() as i64,
284 );
285 SideEffect(move || {
286 if previous.get() != Some(key) {
287 previous.set(Some(key));
288 responder.bring_into_view(caret, ime_bottom);
289 }
290 });
291}
292
293#[composable]
298fn SelectionHandles(
299 state: TextFieldState,
300 style: TextStyle,
301 controller: TextFieldHandleController,
302 accent: Color,
303) {
304 let selection = state.selection();
305 let current_range = (selection.min(), selection.max());
306
307 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
311 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
316 let caret_menu_offset: Rc<Cell<usize>> =
319 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
320 let previous_range: Rc<Cell<(usize, usize)>> =
321 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
322 {
323 let previous_range = Rc::clone(&previous_range);
324 SideEffect(move || {
325 if previous_range.get() != current_range {
326 previous_range.set(current_range);
327 menu_open.set(true);
328 }
329 });
330 }
331 {
334 let caret_menu_offset = Rc::clone(&caret_menu_offset);
335 let caret_start = selection.start;
336 SideEffect(move || {
337 if caret_menu_open.value()
338 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
339 {
340 caret_menu_open.set(false);
341 }
342 });
343 }
344
345 let Some(metrics) = controller.metrics() else {
346 return;
347 };
348 if !metrics.focused || !metrics.touch {
349 return;
350 }
351
352 let text = state.text();
353
354 let drag_pos: MutableState<Option<Point>> =
358 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
359 let drag_bias: Rc<Cell<Option<f32>>> = remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
364
365 if selection.collapsed() {
366 let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
368 let on_drag = drag_caret_closure(
369 state.clone(),
370 style.clone(),
371 controller.clone(),
372 Rc::clone(&drag_bias),
373 );
374 let open_caret_menu = {
377 let caret_menu_offset = Rc::clone(&caret_menu_offset);
378 let state = state.clone();
379 move || {
380 caret_menu_offset.set(state.selection().start);
381 caret_menu_open.set(true);
382 }
383 };
384 let on_tap = open_caret_menu.clone();
385 let on_long_press = open_caret_menu;
386 let grab_bias = Rc::clone(&drag_bias);
387 let end_bias = Rc::clone(&drag_bias);
388 let tip_y = tip.y;
389 SelectionHandle(
390 HandleKind::Cursor,
391 tip,
392 metrics.glyph_box.1,
393 HANDLE_RADIUS,
394 accent,
395 move |pos| {
396 if grab_bias.get().is_none() {
397 grab_bias.set(Some(tip_y - pos.y));
398 }
399 drag_pos.set(Some(pos));
400 on_drag(pos);
401 },
402 move || {
403 drag_pos.set(None);
404 end_bias.set(None);
405 },
406 on_long_press,
407 on_tap,
408 );
409
410 if caret_menu_open.value() {
415 let can_paste = clipboard_read_text().is_some();
416 let can_undo = state.can_undo();
417 let can_redo = state.can_redo();
418 let undo_state = state.clone();
419 let redo_state = state.clone();
420 CaretActionMenu(
421 tip.x,
422 tip.y - metrics.glyph_box.1,
423 drag_pos.value().is_none(),
424 can_paste,
425 can_undo,
426 can_redo,
427 move || {
428 if let Some(text) = clipboard_read_text() {
429 dispatch_paste(&text);
430 }
431 caret_menu_open.set(false);
432 },
433 move || {
434 dispatch_select_all();
435 caret_menu_open.set(false);
436 },
437 move || {
438 undo_state.undo();
439 crate::request_render_invalidation();
440 caret_menu_open.set(false);
441 },
442 move || {
443 redo_state.redo();
444 crate::request_render_invalidation();
445 caret_menu_open.set(false);
446 },
447 );
448 }
449 } else {
450 let start = selection.min();
452 let end = selection.max();
453 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
454 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
455
456 let on_drag_start = drag_edge_closure(
457 HandleKind::SelectionStart,
458 state.clone(),
459 style.clone(),
460 controller.clone(),
461 Rc::clone(&drag_bias),
462 );
463 let grab_bias = Rc::clone(&drag_bias);
464 let end_bias = Rc::clone(&drag_bias);
465 let start_tip_y = start_tip.y;
466 SelectionHandle(
467 HandleKind::SelectionStart,
468 start_tip,
469 metrics.glyph_box.1,
470 HANDLE_RADIUS,
471 accent,
472 move |pos| {
473 if grab_bias.get().is_none() {
474 grab_bias.set(Some(start_tip_y - pos.y));
475 }
476 drag_pos.set(Some(pos));
477 on_drag_start(pos);
478 },
479 move || {
480 drag_pos.set(None);
481 end_bias.set(None);
482 },
483 move || menu_open.set(true),
487 move || menu_open.set(true),
489 );
490
491 let on_drag_end = drag_edge_closure(
492 HandleKind::SelectionEnd,
493 state.clone(),
494 style.clone(),
495 controller.clone(),
496 Rc::clone(&drag_bias),
497 );
498 let grab_bias = Rc::clone(&drag_bias);
499 let end_bias = Rc::clone(&drag_bias);
500 let end_tip_y = end_tip.y;
501 SelectionHandle(
502 HandleKind::SelectionEnd,
503 end_tip,
504 metrics.glyph_box.1,
505 HANDLE_RADIUS,
506 accent,
507 move |pos| {
508 if grab_bias.get().is_none() {
509 grab_bias.set(Some(end_tip_y - pos.y));
510 }
511 drag_pos.set(Some(pos));
512 on_drag_end(pos);
513 },
514 move || {
515 drag_pos.set(None);
516 end_bias.set(None);
517 },
518 move || menu_open.set(true),
519 move || menu_open.set(true),
520 );
521
522 if menu_open.value() {
528 let can_paste = clipboard_read_text().is_some();
529 TextSelectionMenu(
530 (start_tip.x + end_tip.x) * 0.5,
532 start_tip.y - metrics.glyph_box.1,
533 drag_pos.value().is_none(),
534 can_paste,
535 move || {
536 if let Some(text) = dispatch_copy() {
537 clipboard_write_text(&text);
538 }
539 menu_open.set(false);
540 },
541 move || {
542 if let Some(text) = dispatch_cut() {
543 clipboard_write_text(&text);
544 }
545 menu_open.set(false);
546 },
547 move || {
548 if let Some(text) = clipboard_read_text() {
549 dispatch_paste(&text);
550 }
551 menu_open.set(false);
552 },
553 move || {
554 dispatch_select_all();
555 menu_open.set(false);
556 },
557 );
558 }
559 }
560
561 let loupe_target = drag_pos.value().and_then(|finger| {
568 let bias = drag_bias.get().unwrap_or(0.0);
569 let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
570 let line_bottom = handle_tip_window_pos(&text, &style, &metrics, offset).y;
571 loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
572 });
573 SelectionLoupe(loupe_target);
574}
575
576fn drag_caret_closure(
580 state: TextFieldState,
581 style: TextStyle,
582 controller: TextFieldHandleController,
583 drag_bias: Rc<Cell<Option<f32>>>,
584) -> Rc<dyn Fn(Point)> {
585 Rc::new(move |window_pos: Point| {
586 let Some(metrics) = controller.metrics() else {
587 return;
588 };
589 let text = state.text();
590 let bias = drag_bias.get().unwrap_or(0.0);
591 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
592 state.set_selection(TextRange::new(offset, offset));
593 crate::request_render_invalidation();
594 })
595}
596
597fn drag_edge_closure(
601 dragged: HandleKind,
602 state: TextFieldState,
603 style: TextStyle,
604 controller: TextFieldHandleController,
605 drag_bias: Rc<Cell<Option<f32>>>,
606) -> Rc<dyn Fn(Point)> {
607 Rc::new(move |window_pos: Point| {
608 let Some(metrics) = controller.metrics() else {
609 return;
610 };
611 let text = state.text();
612 let bias = drag_bias.get().unwrap_or(0.0);
613 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
614 let selection = state.selection();
615 let fixed_edge = match dragged {
616 HandleKind::SelectionStart => selection.max(),
617 _ => selection.min(),
618 };
619 let (min, max) =
620 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
621 state.set_selection(TextRange::new(min, max));
622 crate::request_render_invalidation();
623 })
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
630 use std::sync::Arc;
631
632 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
634 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
635 f()
636 }
637
638 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
642 use crate::layout::LayoutEngine;
643 use crate::renderer::HeadlessRenderer;
644 use crate::widgets::PopupHost;
645 use cranpose_ui_graphics::Size;
646
647 let mut composition = Composition::new(MemoryApplier::new());
648 let key = location_key(file!(), line!(), column!());
649 let state = TextFieldState::new("hello world");
650
651 let mut content = {
652 let state = state.clone();
653 move || {
654 let state = state.clone();
655 PopupHost(move || {
656 let controller = TextFieldHandleController::new();
657 controller.publish(TextFieldHandleMetrics {
658 focused: true,
659 touch,
660 node_origin: Point { x: 0.0, y: 10.0 },
661 padding_left: 0.0,
662 padding_top: 0.0,
663 scroll_offset: 0.0,
664 line_height: 18.0,
665 glyph_box: (0.0, 18.0),
666 wrap_width: None,
667 });
668 SelectionHandles(
669 state.clone(),
670 TextStyle::default(),
671 controller,
672 Color(0.0, 0.478, 1.0, 1.0),
673 );
674 });
675 }
676 };
677
678 composition.render(key, &mut content).expect("render");
679 for _ in 0..16 {
680 if !composition.should_render() {
681 break;
682 }
683 composition.reconcile(key, &mut content).expect("reconcile");
684 }
685 let root = composition.root().expect("root");
686 let handle = composition.runtime_handle();
687 let mut applier = composition.applier_mut();
688 applier.set_runtime_handle(handle);
689 let layout = applier
690 .compute_layout(
691 root,
692 Size {
693 width: 400.0,
694 height: 400.0,
695 },
696 )
697 .expect("layout");
698 applier.clear_runtime_handle();
699 drop(applier);
700 HeadlessRenderer::new().render(&layout)
701 }
702
703 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
706 use crate::layout::LayoutEngine;
707 use crate::renderer::HeadlessRenderer;
708 use crate::widgets::PopupHost;
709 use cranpose_ui_graphics::Size;
710
711 let mut composition = Composition::new(MemoryApplier::new());
712 let key = location_key(file!(), line!(), column!());
713 let state = TextFieldState::new("hello world");
714
715 let mut content = {
716 let state = state.clone();
717 move || {
718 let state = state.clone();
719 PopupHost(move || {
720 let controller = TextFieldHandleController::new();
721 if state.selection() != TextRange::new(0, 5) {
722 state.set_selection(TextRange::new(0, 5));
723 }
724 controller.publish(TextFieldHandleMetrics {
725 focused: true,
726 touch,
727 node_origin: Point { x: 0.0, y: 40.0 },
728 padding_left: 0.0,
729 padding_top: 0.0,
730 scroll_offset: 0.0,
731 line_height: 18.0,
732 glyph_box: (0.0, 18.0),
733 wrap_width: None,
734 });
735 SelectionHandles(
736 state.clone(),
737 TextStyle::default(),
738 controller,
739 Color(0.0, 0.478, 1.0, 1.0),
740 );
741 });
742 }
743 };
744
745 composition.render(key, &mut content).expect("render");
746 for _ in 0..16 {
747 if !composition.should_render() {
748 break;
749 }
750 composition.reconcile(key, &mut content).expect("reconcile");
751 }
752 let root = composition.root().expect("root");
753 let handle = composition.runtime_handle();
754 let mut applier = composition.applier_mut();
755 applier.set_runtime_handle(handle);
756 let layout = applier
757 .compute_layout(
758 root,
759 Size {
760 width: 400.0,
761 height: 400.0,
762 },
763 )
764 .expect("layout");
765 applier.clear_runtime_handle();
766 drop(applier);
767 HeadlessRenderer::new().render(&layout)
768 }
769
770 fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
776 use crate::layout::LayoutEngine;
777 use crate::renderer::HeadlessRenderer;
778 use crate::widgets::{BoxWithConstraints, PopupHost};
779 use cranpose_ui_graphics::Size;
780
781 let mut composition = Composition::new(MemoryApplier::new());
782 let key = location_key(file!(), line!(), column!());
783 let state = TextFieldState::new("hello world");
784
785 let mut content = {
786 let state = state.clone();
787 move || {
788 let state = state.clone();
789 PopupHost(move || {
790 let state = state.clone();
791 BoxWithConstraints(
792 Modifier::empty().size(Size {
793 width: 300.0,
794 height: 300.0,
795 }),
796 move |_scope| {
797 let controller = TextFieldHandleController::new();
798 if state.selection() != TextRange::new(0, 5) {
799 state.set_selection(TextRange::new(0, 5));
800 }
801 controller.publish(TextFieldHandleMetrics {
802 focused: true,
803 touch,
804 node_origin: Point { x: 0.0, y: 40.0 },
805 padding_left: 0.0,
806 padding_top: 0.0,
807 scroll_offset: 0.0,
808 line_height: 18.0,
809 glyph_box: (0.0, 18.0),
810 wrap_width: None,
811 });
812 SelectionHandles(
813 state.clone(),
814 TextStyle::default(),
815 controller,
816 Color(0.0, 0.478, 1.0, 1.0),
817 );
818 },
819 );
820 });
821 }
822 };
823
824 composition.render(key, &mut content).expect("render");
825 let root = composition.root().expect("root");
826 let handle = composition.runtime_handle();
827 let mut scene = None;
828 for _ in 0..8 {
832 for _ in 0..16 {
833 if !composition.should_render() {
834 break;
835 }
836 composition.reconcile(key, &mut content).expect("reconcile");
837 }
838 let mut applier = composition.applier_mut();
839 applier.set_runtime_handle(handle.clone());
840 let layout = applier
841 .compute_layout(
842 root,
843 Size {
844 width: 400.0,
845 height: 400.0,
846 },
847 )
848 .expect("layout");
849 applier.clear_runtime_handle();
850 drop(applier);
851 scene = Some(HeadlessRenderer::new().render(&layout));
852 }
853 scene.expect("scene")
854 }
855
856 fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
863 use crate::layout::LayoutEngine;
864 use crate::renderer::HeadlessRenderer;
865 use crate::widgets::PopupHost;
866 use crate::{LazyColumn, LazyColumnSpec};
867 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
868 use cranpose_ui_graphics::Size;
869
870 let mut composition = Composition::new(MemoryApplier::new());
871 let key = location_key(file!(), line!(), column!());
872 let state = TextFieldState::new("hello world");
873
874 let mut content = {
875 let state = state.clone();
876 move || {
877 let state = state.clone();
878 PopupHost(move || {
879 let state = state.clone();
880 let list_state = remember_lazy_list_state();
881 LazyColumn(
882 Modifier::empty().size(Size {
883 width: 300.0,
884 height: 300.0,
885 }),
886 list_state,
887 LazyColumnSpec::default(),
888 move |scope| {
889 let state = state.clone();
890 scope.items(
891 1,
892 None::<fn(usize) -> u64>,
893 None::<fn(usize) -> u64>,
894 move |_index| {
895 let controller = TextFieldHandleController::new();
896 if state.selection() != TextRange::new(0, 5) {
897 state.set_selection(TextRange::new(0, 5));
898 }
899 controller.publish(TextFieldHandleMetrics {
900 focused: true,
901 touch,
902 node_origin: Point { x: 0.0, y: 40.0 },
903 padding_left: 0.0,
904 padding_top: 0.0,
905 scroll_offset: 0.0,
906 line_height: 18.0,
907 glyph_box: (0.0, 18.0),
908 wrap_width: None,
909 });
910 SelectionHandles(
911 state.clone(),
912 TextStyle::default(),
913 controller,
914 Color(0.0, 0.478, 1.0, 1.0),
915 );
916 },
917 );
918 },
919 );
920 });
921 }
922 };
923
924 composition.render(key, &mut content).expect("render");
925 let root = composition.root().expect("root");
926 let handle = composition.runtime_handle();
927 let mut scene = None;
928 for _ in 0..8 {
929 for _ in 0..16 {
930 if !composition.should_render() {
931 break;
932 }
933 composition.reconcile(key, &mut content).expect("reconcile");
934 }
935 let mut applier = composition.applier_mut();
936 applier.set_runtime_handle(handle.clone());
937 let layout = applier
938 .compute_layout(
939 root,
940 Size {
941 width: 400.0,
942 height: 400.0,
943 },
944 )
945 .expect("layout");
946 applier.clear_runtime_handle();
947 drop(applier);
948 scene = Some(HeadlessRenderer::new().render(&layout));
949 }
950 scene.expect("scene")
951 }
952
953 #[test]
962 fn field_window_origin_follows_vertical_scroll() {
963 use crate::layout::policies::EmptyMeasurePolicy;
964 use crate::layout::{LayoutBox, LayoutEngine};
965 use crate::renderer::HeadlessRenderer;
966 use crate::scroll::ScrollState;
967 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
968 use cranpose_core::{remember, Key};
969 use cranpose_foundation::modifier_element;
970 use cranpose_ui_graphics::Size;
971 use std::cell::RefCell;
972
973 let _app_context = crate::render_state::app_context_test_scope();
974
975 let mut composition = Composition::new(MemoryApplier::new());
978 let state = TextFieldState::new("hello world");
979 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
980 Rc::new(RefCell::new(None));
981 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
984
985 let spacer_before = 200.0_f32;
986 let mut content = {
987 let state = state.clone();
988 let controller_slot = Rc::clone(&controller_slot);
989 let scroll_slot = Rc::clone(&scroll_slot);
990 move || {
991 let state = state.clone();
992 let controller_slot = Rc::clone(&controller_slot);
993 let scroll_slot = Rc::clone(&scroll_slot);
994 PopupHost(move || {
995 let controller = remember(TextFieldHandleController::new)
996 .with(TextFieldHandleController::clone);
997 *controller_slot.borrow_mut() = Some(controller.clone());
998 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
999 *scroll_slot.borrow_mut() = Some(scroll.clone());
1000 let state = state.clone();
1001 let controller = controller.clone();
1002 Column(
1003 Modifier::empty()
1004 .size(Size {
1005 width: 300.0,
1006 height: 150.0,
1007 })
1008 .vertical_scroll(scroll.clone(), false),
1009 ColumnSpec::default(),
1010 move || {
1011 Spacer(Size {
1012 width: 300.0,
1013 height: spacer_before,
1014 });
1015 let element =
1016 TextFieldElement::new(state.clone(), TextStyle::default())
1017 .with_handle_controller(controller.clone());
1018 let field_modifier =
1019 Modifier::from_parts(vec![modifier_element(element)]);
1020 Layout(field_modifier, EmptyMeasurePolicy, || {});
1021 Spacer(Size {
1022 width: 300.0,
1023 height: 400.0,
1024 });
1025 },
1026 );
1027 });
1028 }
1029 };
1030
1031 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1033 if node
1034 .node_data
1035 .modifier_slices()
1036 .text_field_window_origin()
1037 .is_some()
1038 {
1039 return Some(node.rect);
1040 }
1041 node.children.iter().find_map(find_field_rect)
1042 }
1043
1044 fn layout_and_read(
1045 composition: &mut Composition<MemoryApplier>,
1046 key: Key,
1047 content: &mut dyn FnMut(),
1048 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1049 ) -> (Point, f32) {
1050 for _ in 0..16 {
1051 if !composition.should_render() {
1052 break;
1053 }
1054 composition
1055 .reconcile(key, &mut *content)
1056 .expect("reconcile");
1057 }
1058 let root = composition.root().expect("root");
1059 let handle = composition.runtime_handle();
1060 let mut applier = composition.applier_mut();
1061 applier.set_runtime_handle(handle);
1062 let layout = applier
1063 .compute_layout(
1064 root,
1065 cranpose_ui_graphics::Size {
1066 width: 400.0,
1067 height: 600.0,
1068 },
1069 )
1070 .expect("layout");
1071 applier.clear_runtime_handle();
1072 drop(applier);
1073 let _ = HeadlessRenderer::new().render(&layout);
1074 let field_y = find_field_rect(layout.root()).expect("field placed").y;
1075 let node_origin = controller_slot
1076 .borrow()
1077 .as_ref()
1078 .expect("controller")
1079 .metrics()
1080 .expect("metrics published")
1081 .node_origin;
1082 (node_origin, field_y)
1083 }
1084
1085 let key = location_key(file!(), line!(), column!());
1086 composition.render(key, &mut content).expect("render");
1087
1088 let (origin0, field_y0) =
1089 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1090 assert!(
1093 (origin0.y - field_y0).abs() < 0.5,
1094 "published node_origin.y {} must equal the field's placed window-y {}",
1095 origin0.y,
1096 field_y0
1097 );
1098 assert!(
1099 origin0.y >= spacer_before - 0.5,
1100 "field should start at/after the {spacer_before}px leading spacer, got {}",
1101 origin0.y
1102 );
1103
1104 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
1107 scroll.scroll_to(50.0);
1108 assert!(
1109 scroll.value() >= 49.5,
1110 "test setup: content must be tall enough to scroll 50px (got {})",
1111 scroll.value()
1112 );
1113 let (origin1, field_y1) =
1114 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1115 assert!(
1116 (origin1.y - field_y1).abs() < 0.5,
1117 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1118 origin1.y,
1119 field_y1
1120 );
1121 assert!(
1122 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1123 "scrolling 50px must shift the published field origin up by 50px: \
1124 before {}, after {} (expected {})",
1125 origin0.y,
1126 origin1.y,
1127 origin0.y - 50.0
1128 );
1129 }
1130
1131 #[test]
1137 fn window_offset_roundtrip_holds_under_scroll_offset() {
1138 let _app_context = crate::render_state::app_context_test_scope();
1139 let text = "hello world";
1140 let style = TextStyle::default();
1141 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1143 let metrics = TextFieldHandleMetrics {
1144 focused: true,
1145 touch: true,
1146 node_origin,
1147 padding_left: 4.0,
1148 padding_top: 3.0,
1149 scroll_offset: 0.0,
1150 line_height: 18.0,
1151 glyph_box: (0.0, 18.0),
1152 wrap_width: None,
1153 };
1154 for offset in 0..=text.len() {
1155 if !text.is_char_boundary(offset) {
1156 continue;
1157 }
1158 let tip = handle_tip_window_pos(text, &style, &metrics, offset);
1161 let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1162 assert_eq!(
1163 resolved, offset,
1164 "finger at the tip of offset {offset} must map back to it \
1165 under origin {node_origin:?}, got {resolved}"
1166 );
1167 }
1168 }
1169 }
1170
1171 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1172 use crate::renderer::RenderOp;
1173 scene
1174 .operations()
1175 .iter()
1176 .filter_map(|op| match op {
1177 RenderOp::Text { value, .. } => Some(value.clone()),
1178 _ => None,
1179 })
1180 .collect()
1181 }
1182
1183 fn render_caret_action_menu(
1187 can_paste: bool,
1188 can_undo: bool,
1189 can_redo: bool,
1190 ) -> crate::renderer::RecordedRenderScene {
1191 use crate::layout::LayoutEngine;
1192 use crate::renderer::HeadlessRenderer;
1193 use crate::widgets::PopupHost;
1194 use cranpose_ui_graphics::Size;
1195
1196 let mut composition = Composition::new(MemoryApplier::new());
1197 let key = location_key(file!(), line!(), column!());
1198
1199 let mut content = move || {
1200 PopupHost(move || {
1201 CaretActionMenu(
1202 40.0,
1203 60.0,
1204 true,
1205 can_paste,
1206 can_undo,
1207 can_redo,
1208 || {},
1209 || {},
1210 || {},
1211 || {},
1212 );
1213 });
1214 };
1215
1216 composition.render(key, &mut content).expect("render");
1217 for _ in 0..16 {
1218 if !composition.should_render() {
1219 break;
1220 }
1221 composition.reconcile(key, &mut content).expect("reconcile");
1222 }
1223 let root = composition.root().expect("root");
1224 let handle = composition.runtime_handle();
1225 let mut applier = composition.applier_mut();
1226 applier.set_runtime_handle(handle);
1227 let layout = applier
1228 .compute_layout(
1229 root,
1230 Size {
1231 width: 400.0,
1232 height: 400.0,
1233 },
1234 )
1235 .expect("layout");
1236 applier.clear_runtime_handle();
1237 drop(applier);
1238 HeadlessRenderer::new().render(&layout)
1239 }
1240
1241 #[test]
1245 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1246 let _app_context = crate::render_state::app_context_test_scope();
1247
1248 let all = text_values(&render_caret_action_menu(true, true, true));
1249 for label in ["Paste", "Select all", "Undo", "Redo"] {
1250 assert!(
1251 all.iter().any(|t| t == label),
1252 "caret menu should show {label:?}, got {all:?}"
1253 );
1254 }
1255
1256 let bare = text_values(&render_caret_action_menu(false, false, false));
1258 assert!(
1259 bare.iter().any(|t| t == "Select all"),
1260 "Select all is always available, got {bare:?}"
1261 );
1262 assert!(
1263 !bare
1264 .iter()
1265 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1266 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1267 );
1268 }
1269
1270 #[test]
1271 fn context_menu_shows_for_touch_selection_only() {
1272 let _app_context = crate::render_state::app_context_test_scope();
1273
1274 let touch = text_values(&render_range_menu(true));
1275 assert!(
1276 touch.iter().any(|t| t == "Copy"),
1277 "touch selection should show the Copy menu item, got {touch:?}"
1278 );
1279 assert!(
1280 touch.iter().any(|t| t == "Cut"),
1281 "expected Cut, got {touch:?}"
1282 );
1283 assert!(
1284 touch.iter().any(|t| t == "Select all"),
1285 "expected Select all, got {touch:?}"
1286 );
1287
1288 let mouse = text_values(&render_range_menu(false));
1289 assert!(
1290 !mouse.iter().any(|t| t == "Copy"),
1291 "mouse selection must not show the finger contextual menu, got {mouse:?}"
1292 );
1293 }
1294
1295 #[test]
1296 fn selection_handles_and_menu_survive_subcomposition() {
1297 let _app_context = crate::render_state::app_context_test_scope();
1303 let scene = render_range_menu_subcomposed(true);
1304
1305 let texts = text_values(&scene);
1306 assert!(
1307 texts.iter().any(|t| t == "Copy"),
1308 "a touch selection inside a subcomposition should show the Copy menu \
1309 item through the host, got {texts:?}"
1310 );
1311 assert!(
1312 texts.iter().any(|t| t == "Select all"),
1313 "expected Select all inside a subcomposition, got {texts:?}"
1314 );
1315 assert_eq!(
1316 image_count(&scene),
1317 2,
1318 "a touch range selection should show two finger teardrop handles in \
1319 the overlay across the subcomposition boundary"
1320 );
1321 }
1322
1323 #[test]
1324 fn selection_handles_and_menu_survive_lazy_column_item() {
1325 let _app_context = crate::render_state::app_context_test_scope();
1332 let scene = render_range_menu_lazy_column(true);
1333
1334 let texts = text_values(&scene);
1335 assert!(
1336 texts.iter().any(|t| t == "Copy"),
1337 "a touch selection inside a LazyColumn item should show the Copy menu \
1338 item through the host, got {texts:?}"
1339 );
1340 assert!(
1341 texts.iter().any(|t| t == "Select all"),
1342 "expected Select all inside a LazyColumn item, got {texts:?}"
1343 );
1344 assert_eq!(
1345 image_count(&scene),
1346 2,
1347 "a touch range selection should show two finger teardrop handles in \
1348 the overlay across the LazyColumn item subcomposition boundary"
1349 );
1350 }
1351
1352 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1353 use crate::renderer::RenderOp;
1354 use cranpose_ui_graphics::DrawPrimitive;
1355 scene
1356 .operations()
1357 .iter()
1358 .filter(|op| {
1359 matches!(
1360 op,
1361 RenderOp::Primitive {
1362 primitive: DrawPrimitive::Image { .. },
1363 ..
1364 }
1365 )
1366 })
1367 .count()
1368 }
1369
1370 #[test]
1371 fn cursor_handle_shows_for_touch_only() {
1372 let _app_context = crate::render_state::app_context_test_scope();
1373 assert_eq!(
1374 image_count(&render_collapsed_handles(true)),
1375 1,
1376 "a touch caret should show one finger cursor handle in the overlay"
1377 );
1378 assert_eq!(
1379 image_count(&render_collapsed_handles(false)),
1380 0,
1381 "a mouse caret should keep a clean caret with no finger handle"
1382 );
1383 }
1384
1385 #[test]
1386 fn basic_text_field_creates_node() {
1387 let _app_context = crate::render_state::app_context_test_scope();
1388 let mut composition = Composition::new(MemoryApplier::new());
1389 let state = TextFieldState::new("Test content");
1390
1391 let result = composition.render(location_key(file!(), line!(), column!()), {
1392 let state = state.clone();
1393 move || {
1394 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1395 }
1396 });
1397
1398 assert!(result.is_ok());
1399 assert!(composition.root().is_some());
1400 }
1401
1402 #[test]
1403 fn basic_text_field_state_updates() {
1404 let _app_context = crate::render_state::app_context_test_scope();
1405 with_test_runtime(|| {
1406 let state = TextFieldState::new("Hello");
1407 assert_eq!(state.text(), "Hello");
1408
1409 state.edit(|buffer| {
1410 buffer.place_cursor_at_end();
1411 buffer.insert("!");
1412 });
1413
1414 assert_eq!(state.text(), "Hello!");
1415 });
1416 }
1417
1418 #[test]
1427 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1428 use crate::bring_into_view::local_bring_into_view_responder;
1429 use crate::layout::LayoutEngine;
1430 use crate::renderer::HeadlessRenderer;
1431 use crate::widgets::{Box, BoxSpec, PopupHost};
1432 use crate::{LazyColumn, LazyColumnSpec};
1433 use cranpose_core::Key;
1434 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1435 use cranpose_ui_graphics::Size;
1436 use std::cell::RefCell;
1437
1438 let _app_context = crate::render_state::app_context_test_scope();
1439 let mut composition = Composition::new(MemoryApplier::new());
1440 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1441 Rc::new(RefCell::new(None));
1442 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1443
1444 let mut content = {
1447 let responder_slot = Rc::clone(&responder_slot);
1448 let state_slot = Rc::clone(&state_slot);
1449 move || {
1450 let responder_slot = Rc::clone(&responder_slot);
1451 let state_slot = Rc::clone(&state_slot);
1452 PopupHost(move || {
1453 let list_state = remember_lazy_list_state();
1454 *state_slot.borrow_mut() = Some(list_state);
1455 let responder_slot = Rc::clone(&responder_slot);
1456 LazyColumn(
1457 Modifier::empty().size(Size {
1458 width: 300.0,
1459 height: 400.0,
1460 }),
1461 list_state,
1462 LazyColumnSpec::default(),
1463 move |scope| {
1464 let responder_slot = Rc::clone(&responder_slot);
1465 scope.items(
1466 30,
1467 None::<fn(usize) -> u64>,
1468 None::<fn(usize) -> u64>,
1469 move |_index| {
1470 if responder_slot.borrow().is_none() {
1471 if let Some(r) = local_bring_into_view_responder().current()
1472 {
1473 *responder_slot.borrow_mut() = Some(r);
1474 }
1475 }
1476 Box(
1477 Modifier::empty().size(Size {
1478 width: 300.0,
1479 height: 80.0,
1480 }),
1481 BoxSpec::default(),
1482 || {},
1483 );
1484 },
1485 );
1486 },
1487 );
1488 });
1489 }
1490 };
1491
1492 fn run_layout(
1493 composition: &mut Composition<MemoryApplier>,
1494 key: Key,
1495 content: &mut dyn FnMut(),
1496 ) {
1497 for _ in 0..16 {
1498 if !composition.should_render() {
1499 break;
1500 }
1501 composition
1502 .reconcile(key, &mut *content)
1503 .expect("reconcile");
1504 }
1505 let root = composition.root().expect("root");
1506 let handle = composition.runtime_handle();
1507 let mut applier = composition.applier_mut();
1508 applier.set_runtime_handle(handle);
1509 let layout = applier
1510 .compute_layout(
1511 root,
1512 Size {
1513 width: 400.0,
1514 height: 600.0,
1515 },
1516 )
1517 .expect("layout");
1518 applier.clear_runtime_handle();
1519 drop(applier);
1520 let _ = HeadlessRenderer::new().render(&layout);
1521 }
1522
1523 let key = location_key(file!(), line!(), column!());
1524 composition.render(key, &mut content).expect("render");
1525 run_layout(&mut composition, key, &mut content);
1526
1527 let responder = responder_slot
1528 .borrow()
1529 .clone()
1530 .expect("LazyColumn provides a bring-into-view responder to its items");
1531 let list_state = state_slot.borrow().expect("list state captured");
1532 let offset0 = list_state.first_visible_item_scroll_offset();
1533 let index0 = list_state.first_visible_item_index();
1534
1535 responder.bring_into_view(
1538 Rect {
1539 x: 10.0,
1540 y: 100.0,
1541 width: 2.0,
1542 height: 20.0,
1543 },
1544 0.0,
1545 );
1546 run_layout(&mut composition, key, &mut content);
1547 assert_eq!(
1548 list_state.first_visible_item_index(),
1549 index0,
1550 "an already-visible caret must not scroll the list"
1551 );
1552 assert!(
1553 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1554 "an already-visible caret must not scroll the list"
1555 );
1556
1557 responder.bring_into_view(
1560 Rect {
1561 x: 10.0,
1562 y: 360.0,
1563 width: 2.0,
1564 height: 20.0,
1565 },
1566 250.0,
1567 );
1568 run_layout(&mut composition, key, &mut content);
1569 let scrolled_forward = list_state.first_visible_item_index() > index0
1570 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1571 assert!(
1572 scrolled_forward,
1573 "a caret behind the keyboard must scroll the list forward \
1574 (index {} -> {}, offset {:.1} -> {:.1})",
1575 index0,
1576 list_state.first_visible_item_index(),
1577 offset0,
1578 list_state.first_visible_item_scroll_offset(),
1579 );
1580 }
1581}