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::{CursorMagnifier, Layout, SelectionHandle, TextSelectionMenu};
21use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
22use cranpose_foundation::modifier_element;
23use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
24use cranpose_ui_graphics::{Color, Point, Rect};
25use std::cell::Cell;
26use std::rc::Rc;
27
28const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
30
31fn handle_tip_window_pos(
34 text: &str,
35 style: &TextStyle,
36 metrics: &TextFieldHandleMetrics,
37 offset: usize,
38) -> Point {
39 let offset = offset.min(text.len());
40 let before = &text[..offset];
41 let line_index = before.matches('\n').count();
42 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
43 let caret_x = measure_text(&AnnotatedString::from(&before[line_start..]), style).width;
44 Point {
45 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
46 y: metrics.node_origin.y
47 + metrics.padding_top
48 + (line_index as f32 + 1.0) * metrics.line_height,
49 }
50}
51
52fn window_pos_to_offset(
55 text: &str,
56 style: &TextStyle,
57 metrics: &TextFieldHandleMetrics,
58 window_pos: Point,
59) -> usize {
60 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
61 + metrics.scroll_offset)
62 .max(0.0);
63 let local_y =
66 (window_pos.y - metrics.node_origin.y - metrics.padding_top - metrics.line_height).max(0.0);
67 crate::text::get_offset_for_position(&AnnotatedString::from(text), style, local_x, local_y)
68}
69#[composable]
87pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
88 BasicTextFieldWithOptions(
89 state,
90 modifier,
91 BasicTextFieldOptions {
92 text_style: style,
93 ..BasicTextFieldOptions::default()
94 },
95 )
96}
97
98#[derive(Debug, Clone, PartialEq)]
100pub struct BasicTextFieldOptions {
101 pub text_style: TextStyle,
103 pub cursor_color: Color,
105 pub line_limits: TextFieldLineLimits,
107}
108
109impl Default for BasicTextFieldOptions {
110 fn default() -> Self {
111 Self {
112 text_style: TextStyle::default(),
113 cursor_color: Color(0.0, 0.0, 0.0, 1.0), line_limits: TextFieldLineLimits::default(),
115 }
116 }
117}
118
119#[composable]
123pub fn BasicTextFieldWithOptions(
124 state: TextFieldState,
125 modifier: Modifier,
126 options: BasicTextFieldOptions,
127) -> NodeId {
128 let _text = state.text();
131 let _selection = state.selection();
132
133 let controller =
137 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
138
139 let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
141 .with_cursor_color(options.cursor_color)
142 .with_line_limits(options.line_limits)
143 .with_handle_controller(controller.clone());
144
145 let text_field_modifier = modifier_element(text_field_element);
147 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
148 let combined_modifier = modifier.then(final_modifier);
149
150 let node = Layout(
153 combined_modifier,
154 EmptyMeasurePolicy,
155 || {}, );
157
158 BringCaretIntoView(
163 state.clone(),
164 options.text_style.clone(),
165 controller.clone(),
166 );
167
168 SelectionHandles(state, options.text_style, controller);
174
175 node
176}
177
178fn caret_window_rect(
182 text: &str,
183 style: &TextStyle,
184 metrics: &TextFieldHandleMetrics,
185 offset: usize,
186) -> Rect {
187 let tip = handle_tip_window_pos(text, style, metrics, offset);
190 Rect {
191 x: tip.x,
192 y: tip.y - metrics.line_height,
193 width: 2.0,
194 height: metrics.line_height,
195 }
196}
197
198#[composable]
208fn BringCaretIntoView(
209 state: TextFieldState,
210 style: TextStyle,
211 controller: TextFieldHandleController,
212) {
213 let Some(metrics) = controller.metrics() else {
214 return;
215 };
216 let ime_bottom = local_ime_insets().current().bottom;
219 let responder = local_bring_into_view_responder().current();
220
221 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
222 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
223
224 if !metrics.focused {
225 previous.set(None);
228 return;
229 }
230 let Some(responder) = responder else {
231 return;
232 };
233
234 let text = state.text();
235 let selection = state.selection();
236 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
237
238 let key = (
242 selection.start,
243 selection.end,
244 (ime_bottom * 4.0).round() as i64,
245 );
246 SideEffect(move || {
247 if previous.get() != Some(key) {
248 previous.set(Some(key));
249 responder.bring_into_view(caret, ime_bottom);
250 }
251 });
252}
253
254#[composable]
257fn SelectionHandles(
258 state: TextFieldState,
259 style: TextStyle,
260 controller: TextFieldHandleController,
261) {
262 let selection = state.selection();
263 let current_range = (selection.min(), selection.max());
264
265 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
269 let previous_range: Rc<Cell<(usize, usize)>> =
270 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
271 {
272 let previous_range = Rc::clone(&previous_range);
273 SideEffect(move || {
274 if previous_range.get() != current_range {
275 previous_range.set(current_range);
276 menu_open.set(true);
277 }
278 });
279 }
280
281 let Some(metrics) = controller.metrics() else {
282 return;
283 };
284 if !metrics.focused || !metrics.touch {
285 return;
286 }
287
288 let text = state.text();
289
290 let drag_pos: MutableState<Option<Point>> =
294 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
295
296 if selection.collapsed() {
297 let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
299 let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
300 SelectionHandle(
301 HandleKind::Cursor,
302 tip,
303 HANDLE_RADIUS,
304 HANDLE_COLOR,
305 move |pos| {
306 drag_pos.set(Some(pos));
307 on_drag(pos);
308 },
309 move || drag_pos.set(None),
310 || {},
311 );
312 } else {
313 let start = selection.min();
315 let end = selection.max();
316 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
317 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
318
319 let on_drag_start = drag_edge_closure(
320 HandleKind::SelectionStart,
321 state.clone(),
322 style.clone(),
323 controller.clone(),
324 );
325 SelectionHandle(
326 HandleKind::SelectionStart,
327 start_tip,
328 HANDLE_RADIUS,
329 HANDLE_COLOR,
330 move |pos| {
331 drag_pos.set(Some(pos));
332 on_drag_start(pos);
333 },
334 move || drag_pos.set(None),
335 move || menu_open.set(true),
339 );
340
341 let on_drag_end = drag_edge_closure(
342 HandleKind::SelectionEnd,
343 state.clone(),
344 style.clone(),
345 controller.clone(),
346 );
347 SelectionHandle(
348 HandleKind::SelectionEnd,
349 end_tip,
350 HANDLE_RADIUS,
351 HANDLE_COLOR,
352 move |pos| {
353 drag_pos.set(Some(pos));
354 on_drag_end(pos);
355 },
356 move || drag_pos.set(None),
357 move || menu_open.set(true),
358 );
359
360 if menu_open.value() {
363 let menu_anchor = Point {
365 x: (start_tip.x + end_tip.x) * 0.5,
366 y: start_tip.y - metrics.line_height,
367 };
368 let can_paste = clipboard_read_text().is_some();
369 TextSelectionMenu(
370 menu_anchor,
371 can_paste,
372 move || {
373 if let Some(text) = dispatch_copy() {
374 clipboard_write_text(&text);
375 }
376 menu_open.set(false);
377 },
378 move || {
379 if let Some(text) = dispatch_cut() {
380 clipboard_write_text(&text);
381 }
382 menu_open.set(false);
383 },
384 move || {
385 if let Some(text) = clipboard_read_text() {
386 dispatch_paste(&text);
387 }
388 menu_open.set(false);
389 },
390 move || {
391 dispatch_select_all();
392 menu_open.set(false);
393 },
394 );
395 }
396 }
397
398 if let Some(finger) = drag_pos.value() {
405 let offset = window_pos_to_offset(&text, &style, &metrics, finger);
406 CursorMagnifier(text.clone(), style.clone(), metrics, offset, finger);
407 }
408}
409
410fn drag_caret_closure(
413 state: TextFieldState,
414 style: TextStyle,
415 controller: TextFieldHandleController,
416) -> Rc<dyn Fn(Point)> {
417 Rc::new(move |window_pos: Point| {
418 let Some(metrics) = controller.metrics() else {
419 return;
420 };
421 let text = state.text();
422 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
423 state.set_selection(TextRange::new(offset, offset));
424 crate::request_render_invalidation();
425 })
426}
427
428fn drag_edge_closure(
432 dragged: HandleKind,
433 state: TextFieldState,
434 style: TextStyle,
435 controller: TextFieldHandleController,
436) -> Rc<dyn Fn(Point)> {
437 Rc::new(move |window_pos: Point| {
438 let Some(metrics) = controller.metrics() else {
439 return;
440 };
441 let text = state.text();
442 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
443 let selection = state.selection();
444 let fixed_edge = match dragged {
445 HandleKind::SelectionStart => selection.max(),
446 _ => selection.min(),
447 };
448 let (min, max) =
449 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
450 state.set_selection(TextRange::new(min, max));
451 crate::request_render_invalidation();
452 })
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
459 use std::sync::Arc;
460
461 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
463 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
464 f()
465 }
466
467 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
471 use crate::layout::LayoutEngine;
472 use crate::renderer::HeadlessRenderer;
473 use crate::widgets::PopupHost;
474 use cranpose_ui_graphics::Size;
475
476 let mut composition = Composition::new(MemoryApplier::new());
477 let key = location_key(file!(), line!(), column!());
478 let state = TextFieldState::new("hello world");
479
480 let mut content = {
481 let state = state.clone();
482 move || {
483 let state = state.clone();
484 PopupHost(move || {
485 let controller = TextFieldHandleController::new();
486 controller.publish(TextFieldHandleMetrics {
487 focused: true,
488 touch,
489 node_origin: Point { x: 0.0, y: 10.0 },
490 padding_left: 0.0,
491 padding_top: 0.0,
492 scroll_offset: 0.0,
493 line_height: 18.0,
494 });
495 SelectionHandles(state.clone(), TextStyle::default(), controller);
496 });
497 }
498 };
499
500 composition.render(key, &mut content).expect("render");
501 for _ in 0..16 {
502 if !composition.should_render() {
503 break;
504 }
505 composition.reconcile(key, &mut content).expect("reconcile");
506 }
507 let root = composition.root().expect("root");
508 let handle = composition.runtime_handle();
509 let mut applier = composition.applier_mut();
510 applier.set_runtime_handle(handle);
511 let layout = applier
512 .compute_layout(
513 root,
514 Size {
515 width: 400.0,
516 height: 400.0,
517 },
518 )
519 .expect("layout");
520 applier.clear_runtime_handle();
521 drop(applier);
522 HeadlessRenderer::new().render(&layout)
523 }
524
525 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
528 use crate::layout::LayoutEngine;
529 use crate::renderer::HeadlessRenderer;
530 use crate::widgets::PopupHost;
531 use cranpose_ui_graphics::Size;
532
533 let mut composition = Composition::new(MemoryApplier::new());
534 let key = location_key(file!(), line!(), column!());
535 let state = TextFieldState::new("hello world");
536
537 let mut content = {
538 let state = state.clone();
539 move || {
540 let state = state.clone();
541 PopupHost(move || {
542 let controller = TextFieldHandleController::new();
543 if state.selection() != TextRange::new(0, 5) {
544 state.set_selection(TextRange::new(0, 5));
545 }
546 controller.publish(TextFieldHandleMetrics {
547 focused: true,
548 touch,
549 node_origin: Point { x: 0.0, y: 40.0 },
550 padding_left: 0.0,
551 padding_top: 0.0,
552 scroll_offset: 0.0,
553 line_height: 18.0,
554 });
555 SelectionHandles(state.clone(), TextStyle::default(), controller);
556 });
557 }
558 };
559
560 composition.render(key, &mut content).expect("render");
561 for _ in 0..16 {
562 if !composition.should_render() {
563 break;
564 }
565 composition.reconcile(key, &mut content).expect("reconcile");
566 }
567 let root = composition.root().expect("root");
568 let handle = composition.runtime_handle();
569 let mut applier = composition.applier_mut();
570 applier.set_runtime_handle(handle);
571 let layout = applier
572 .compute_layout(
573 root,
574 Size {
575 width: 400.0,
576 height: 400.0,
577 },
578 )
579 .expect("layout");
580 applier.clear_runtime_handle();
581 drop(applier);
582 HeadlessRenderer::new().render(&layout)
583 }
584
585 fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
591 use crate::layout::LayoutEngine;
592 use crate::renderer::HeadlessRenderer;
593 use crate::widgets::{BoxWithConstraints, PopupHost};
594 use cranpose_ui_graphics::Size;
595
596 let mut composition = Composition::new(MemoryApplier::new());
597 let key = location_key(file!(), line!(), column!());
598 let state = TextFieldState::new("hello world");
599
600 let mut content = {
601 let state = state.clone();
602 move || {
603 let state = state.clone();
604 PopupHost(move || {
605 let state = state.clone();
606 BoxWithConstraints(
607 Modifier::empty().size(Size {
608 width: 300.0,
609 height: 300.0,
610 }),
611 move |_scope| {
612 let controller = TextFieldHandleController::new();
613 if state.selection() != TextRange::new(0, 5) {
614 state.set_selection(TextRange::new(0, 5));
615 }
616 controller.publish(TextFieldHandleMetrics {
617 focused: true,
618 touch,
619 node_origin: Point { x: 0.0, y: 40.0 },
620 padding_left: 0.0,
621 padding_top: 0.0,
622 scroll_offset: 0.0,
623 line_height: 18.0,
624 });
625 SelectionHandles(state.clone(), TextStyle::default(), controller);
626 },
627 );
628 });
629 }
630 };
631
632 composition.render(key, &mut content).expect("render");
633 let root = composition.root().expect("root");
634 let handle = composition.runtime_handle();
635 let mut scene = None;
636 for _ in 0..8 {
640 for _ in 0..16 {
641 if !composition.should_render() {
642 break;
643 }
644 composition.reconcile(key, &mut content).expect("reconcile");
645 }
646 let mut applier = composition.applier_mut();
647 applier.set_runtime_handle(handle.clone());
648 let layout = applier
649 .compute_layout(
650 root,
651 Size {
652 width: 400.0,
653 height: 400.0,
654 },
655 )
656 .expect("layout");
657 applier.clear_runtime_handle();
658 drop(applier);
659 scene = Some(HeadlessRenderer::new().render(&layout));
660 }
661 scene.expect("scene")
662 }
663
664 fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
671 use crate::layout::LayoutEngine;
672 use crate::renderer::HeadlessRenderer;
673 use crate::widgets::PopupHost;
674 use crate::{LazyColumn, LazyColumnSpec};
675 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
676 use cranpose_ui_graphics::Size;
677
678 let mut composition = Composition::new(MemoryApplier::new());
679 let key = location_key(file!(), line!(), column!());
680 let state = TextFieldState::new("hello world");
681
682 let mut content = {
683 let state = state.clone();
684 move || {
685 let state = state.clone();
686 PopupHost(move || {
687 let state = state.clone();
688 let list_state = remember_lazy_list_state();
689 LazyColumn(
690 Modifier::empty().size(Size {
691 width: 300.0,
692 height: 300.0,
693 }),
694 list_state,
695 LazyColumnSpec::default(),
696 move |scope| {
697 let state = state.clone();
698 scope.items(
699 1,
700 None::<fn(usize) -> u64>,
701 None::<fn(usize) -> u64>,
702 move |_index| {
703 let controller = TextFieldHandleController::new();
704 if state.selection() != TextRange::new(0, 5) {
705 state.set_selection(TextRange::new(0, 5));
706 }
707 controller.publish(TextFieldHandleMetrics {
708 focused: true,
709 touch,
710 node_origin: Point { x: 0.0, y: 40.0 },
711 padding_left: 0.0,
712 padding_top: 0.0,
713 scroll_offset: 0.0,
714 line_height: 18.0,
715 });
716 SelectionHandles(
717 state.clone(),
718 TextStyle::default(),
719 controller,
720 );
721 },
722 );
723 },
724 );
725 });
726 }
727 };
728
729 composition.render(key, &mut content).expect("render");
730 let root = composition.root().expect("root");
731 let handle = composition.runtime_handle();
732 let mut scene = None;
733 for _ in 0..8 {
734 for _ in 0..16 {
735 if !composition.should_render() {
736 break;
737 }
738 composition.reconcile(key, &mut content).expect("reconcile");
739 }
740 let mut applier = composition.applier_mut();
741 applier.set_runtime_handle(handle.clone());
742 let layout = applier
743 .compute_layout(
744 root,
745 Size {
746 width: 400.0,
747 height: 400.0,
748 },
749 )
750 .expect("layout");
751 applier.clear_runtime_handle();
752 drop(applier);
753 scene = Some(HeadlessRenderer::new().render(&layout));
754 }
755 scene.expect("scene")
756 }
757
758 #[test]
767 fn field_window_origin_follows_vertical_scroll() {
768 use crate::layout::policies::EmptyMeasurePolicy;
769 use crate::layout::{LayoutBox, LayoutEngine};
770 use crate::renderer::HeadlessRenderer;
771 use crate::scroll::ScrollState;
772 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
773 use cranpose_core::{remember, Key};
774 use cranpose_foundation::modifier_element;
775 use cranpose_ui_graphics::Size;
776 use std::cell::RefCell;
777
778 let _app_context = crate::render_state::app_context_test_scope();
779
780 let mut composition = Composition::new(MemoryApplier::new());
783 let state = TextFieldState::new("hello world");
784 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
785 Rc::new(RefCell::new(None));
786 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
789
790 let spacer_before = 200.0_f32;
791 let mut content = {
792 let state = state.clone();
793 let controller_slot = Rc::clone(&controller_slot);
794 let scroll_slot = Rc::clone(&scroll_slot);
795 move || {
796 let state = state.clone();
797 let controller_slot = Rc::clone(&controller_slot);
798 let scroll_slot = Rc::clone(&scroll_slot);
799 PopupHost(move || {
800 let controller = remember(TextFieldHandleController::new)
801 .with(TextFieldHandleController::clone);
802 *controller_slot.borrow_mut() = Some(controller.clone());
803 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
804 *scroll_slot.borrow_mut() = Some(scroll.clone());
805 let state = state.clone();
806 let controller = controller.clone();
807 Column(
808 Modifier::empty()
809 .size(Size {
810 width: 300.0,
811 height: 150.0,
812 })
813 .vertical_scroll(scroll.clone(), false),
814 ColumnSpec::default(),
815 move || {
816 Spacer(Size {
817 width: 300.0,
818 height: spacer_before,
819 });
820 let element =
821 TextFieldElement::new(state.clone(), TextStyle::default())
822 .with_handle_controller(controller.clone());
823 let field_modifier =
824 Modifier::from_parts(vec![modifier_element(element)]);
825 Layout(field_modifier, EmptyMeasurePolicy, || {});
826 Spacer(Size {
827 width: 300.0,
828 height: 400.0,
829 });
830 },
831 );
832 });
833 }
834 };
835
836 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
838 if node
839 .node_data
840 .modifier_slices()
841 .text_field_window_origin()
842 .is_some()
843 {
844 return Some(node.rect);
845 }
846 node.children.iter().find_map(find_field_rect)
847 }
848
849 fn layout_and_read(
850 composition: &mut Composition<MemoryApplier>,
851 key: Key,
852 content: &mut dyn FnMut(),
853 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
854 ) -> (Point, f32) {
855 for _ in 0..16 {
856 if !composition.should_render() {
857 break;
858 }
859 composition
860 .reconcile(key, &mut *content)
861 .expect("reconcile");
862 }
863 let root = composition.root().expect("root");
864 let handle = composition.runtime_handle();
865 let mut applier = composition.applier_mut();
866 applier.set_runtime_handle(handle);
867 let layout = applier
868 .compute_layout(
869 root,
870 cranpose_ui_graphics::Size {
871 width: 400.0,
872 height: 600.0,
873 },
874 )
875 .expect("layout");
876 applier.clear_runtime_handle();
877 drop(applier);
878 let _ = HeadlessRenderer::new().render(&layout);
879 let field_y = find_field_rect(layout.root()).expect("field placed").y;
880 let node_origin = controller_slot
881 .borrow()
882 .as_ref()
883 .expect("controller")
884 .metrics()
885 .expect("metrics published")
886 .node_origin;
887 (node_origin, field_y)
888 }
889
890 let key = location_key(file!(), line!(), column!());
891 composition.render(key, &mut content).expect("render");
892
893 let (origin0, field_y0) =
894 layout_and_read(&mut composition, key, &mut content, &controller_slot);
895 assert!(
898 (origin0.y - field_y0).abs() < 0.5,
899 "published node_origin.y {} must equal the field's placed window-y {}",
900 origin0.y,
901 field_y0
902 );
903 assert!(
904 origin0.y >= spacer_before - 0.5,
905 "field should start at/after the {spacer_before}px leading spacer, got {}",
906 origin0.y
907 );
908
909 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
912 scroll.scroll_to(50.0);
913 assert!(
914 scroll.value() >= 49.5,
915 "test setup: content must be tall enough to scroll 50px (got {})",
916 scroll.value()
917 );
918 let (origin1, field_y1) =
919 layout_and_read(&mut composition, key, &mut content, &controller_slot);
920 assert!(
921 (origin1.y - field_y1).abs() < 0.5,
922 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
923 origin1.y,
924 field_y1
925 );
926 assert!(
927 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
928 "scrolling 50px must shift the published field origin up by 50px: \
929 before {}, after {} (expected {})",
930 origin0.y,
931 origin1.y,
932 origin0.y - 50.0
933 );
934 }
935
936 #[test]
942 fn window_offset_roundtrip_holds_under_scroll_offset() {
943 let _app_context = crate::render_state::app_context_test_scope();
944 let text = "hello world";
945 let style = TextStyle::default();
946 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
948 let metrics = TextFieldHandleMetrics {
949 focused: true,
950 touch: true,
951 node_origin,
952 padding_left: 4.0,
953 padding_top: 3.0,
954 scroll_offset: 0.0,
955 line_height: 18.0,
956 };
957 for offset in 0..=text.len() {
958 if !text.is_char_boundary(offset) {
959 continue;
960 }
961 let tip = handle_tip_window_pos(text, &style, &metrics, offset);
962 let resolved = window_pos_to_offset(text, &style, &metrics, tip);
963 assert_eq!(
964 resolved, offset,
965 "finger at the tip of offset {offset} must map back to it \
966 under origin {node_origin:?}, got {resolved}"
967 );
968 }
969 }
970 }
971
972 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
973 use crate::renderer::RenderOp;
974 scene
975 .operations()
976 .iter()
977 .filter_map(|op| match op {
978 RenderOp::Text { value, .. } => Some(value.clone()),
979 _ => None,
980 })
981 .collect()
982 }
983
984 #[test]
985 fn context_menu_shows_for_touch_selection_only() {
986 let _app_context = crate::render_state::app_context_test_scope();
987
988 let touch = text_values(&render_range_menu(true));
989 assert!(
990 touch.iter().any(|t| t == "Copy"),
991 "touch selection should show the Copy menu item, got {touch:?}"
992 );
993 assert!(
994 touch.iter().any(|t| t == "Cut"),
995 "expected Cut, got {touch:?}"
996 );
997 assert!(
998 touch.iter().any(|t| t == "Select all"),
999 "expected Select all, got {touch:?}"
1000 );
1001
1002 let mouse = text_values(&render_range_menu(false));
1003 assert!(
1004 !mouse.iter().any(|t| t == "Copy"),
1005 "mouse selection must not show the finger contextual menu, got {mouse:?}"
1006 );
1007 }
1008
1009 #[test]
1010 fn selection_handles_and_menu_survive_subcomposition() {
1011 let _app_context = crate::render_state::app_context_test_scope();
1017 let scene = render_range_menu_subcomposed(true);
1018
1019 let texts = text_values(&scene);
1020 assert!(
1021 texts.iter().any(|t| t == "Copy"),
1022 "a touch selection inside a subcomposition should show the Copy menu \
1023 item through the host, got {texts:?}"
1024 );
1025 assert!(
1026 texts.iter().any(|t| t == "Select all"),
1027 "expected Select all inside a subcomposition, got {texts:?}"
1028 );
1029 assert_eq!(
1030 image_count(&scene),
1031 2,
1032 "a touch range selection should show two finger teardrop handles in \
1033 the overlay across the subcomposition boundary"
1034 );
1035 }
1036
1037 #[test]
1038 fn selection_handles_and_menu_survive_lazy_column_item() {
1039 let _app_context = crate::render_state::app_context_test_scope();
1046 let scene = render_range_menu_lazy_column(true);
1047
1048 let texts = text_values(&scene);
1049 assert!(
1050 texts.iter().any(|t| t == "Copy"),
1051 "a touch selection inside a LazyColumn item should show the Copy menu \
1052 item through the host, got {texts:?}"
1053 );
1054 assert!(
1055 texts.iter().any(|t| t == "Select all"),
1056 "expected Select all inside a LazyColumn item, got {texts:?}"
1057 );
1058 assert_eq!(
1059 image_count(&scene),
1060 2,
1061 "a touch range selection should show two finger teardrop handles in \
1062 the overlay across the LazyColumn item subcomposition boundary"
1063 );
1064 }
1065
1066 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1067 use crate::renderer::RenderOp;
1068 use cranpose_ui_graphics::DrawPrimitive;
1069 scene
1070 .operations()
1071 .iter()
1072 .filter(|op| {
1073 matches!(
1074 op,
1075 RenderOp::Primitive {
1076 primitive: DrawPrimitive::Image { .. },
1077 ..
1078 }
1079 )
1080 })
1081 .count()
1082 }
1083
1084 #[test]
1085 fn cursor_handle_shows_for_touch_only() {
1086 let _app_context = crate::render_state::app_context_test_scope();
1087 assert_eq!(
1088 image_count(&render_collapsed_handles(true)),
1089 1,
1090 "a touch caret should show one finger cursor handle in the overlay"
1091 );
1092 assert_eq!(
1093 image_count(&render_collapsed_handles(false)),
1094 0,
1095 "a mouse caret should keep a clean caret with no finger handle"
1096 );
1097 }
1098
1099 #[test]
1100 fn basic_text_field_creates_node() {
1101 let _app_context = crate::render_state::app_context_test_scope();
1102 let mut composition = Composition::new(MemoryApplier::new());
1103 let state = TextFieldState::new("Test content");
1104
1105 let result = composition.render(location_key(file!(), line!(), column!()), {
1106 let state = state.clone();
1107 move || {
1108 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1109 }
1110 });
1111
1112 assert!(result.is_ok());
1113 assert!(composition.root().is_some());
1114 }
1115
1116 #[test]
1117 fn basic_text_field_state_updates() {
1118 let _app_context = crate::render_state::app_context_test_scope();
1119 with_test_runtime(|| {
1120 let state = TextFieldState::new("Hello");
1121 assert_eq!(state.text(), "Hello");
1122
1123 state.edit(|buffer| {
1124 buffer.place_cursor_at_end();
1125 buffer.insert("!");
1126 });
1127
1128 assert_eq!(state.text(), "Hello!");
1129 });
1130 }
1131
1132 #[test]
1141 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1142 use crate::bring_into_view::local_bring_into_view_responder;
1143 use crate::layout::LayoutEngine;
1144 use crate::renderer::HeadlessRenderer;
1145 use crate::widgets::{Box, BoxSpec, PopupHost};
1146 use crate::{LazyColumn, LazyColumnSpec};
1147 use cranpose_core::Key;
1148 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1149 use cranpose_ui_graphics::Size;
1150 use std::cell::RefCell;
1151
1152 let _app_context = crate::render_state::app_context_test_scope();
1153 let mut composition = Composition::new(MemoryApplier::new());
1154 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1155 Rc::new(RefCell::new(None));
1156 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1157
1158 let mut content = {
1161 let responder_slot = Rc::clone(&responder_slot);
1162 let state_slot = Rc::clone(&state_slot);
1163 move || {
1164 let responder_slot = Rc::clone(&responder_slot);
1165 let state_slot = Rc::clone(&state_slot);
1166 PopupHost(move || {
1167 let list_state = remember_lazy_list_state();
1168 *state_slot.borrow_mut() = Some(list_state);
1169 let responder_slot = Rc::clone(&responder_slot);
1170 LazyColumn(
1171 Modifier::empty().size(Size {
1172 width: 300.0,
1173 height: 400.0,
1174 }),
1175 list_state,
1176 LazyColumnSpec::default(),
1177 move |scope| {
1178 let responder_slot = Rc::clone(&responder_slot);
1179 scope.items(
1180 30,
1181 None::<fn(usize) -> u64>,
1182 None::<fn(usize) -> u64>,
1183 move |_index| {
1184 if responder_slot.borrow().is_none() {
1185 if let Some(r) = local_bring_into_view_responder().current()
1186 {
1187 *responder_slot.borrow_mut() = Some(r);
1188 }
1189 }
1190 Box(
1191 Modifier::empty().size(Size {
1192 width: 300.0,
1193 height: 80.0,
1194 }),
1195 BoxSpec::default(),
1196 || {},
1197 );
1198 },
1199 );
1200 },
1201 );
1202 });
1203 }
1204 };
1205
1206 fn run_layout(
1207 composition: &mut Composition<MemoryApplier>,
1208 key: Key,
1209 content: &mut dyn FnMut(),
1210 ) {
1211 for _ in 0..16 {
1212 if !composition.should_render() {
1213 break;
1214 }
1215 composition
1216 .reconcile(key, &mut *content)
1217 .expect("reconcile");
1218 }
1219 let root = composition.root().expect("root");
1220 let handle = composition.runtime_handle();
1221 let mut applier = composition.applier_mut();
1222 applier.set_runtime_handle(handle);
1223 let layout = applier
1224 .compute_layout(
1225 root,
1226 Size {
1227 width: 400.0,
1228 height: 600.0,
1229 },
1230 )
1231 .expect("layout");
1232 applier.clear_runtime_handle();
1233 drop(applier);
1234 let _ = HeadlessRenderer::new().render(&layout);
1235 }
1236
1237 let key = location_key(file!(), line!(), column!());
1238 composition.render(key, &mut content).expect("render");
1239 run_layout(&mut composition, key, &mut content);
1240
1241 let responder = responder_slot
1242 .borrow()
1243 .clone()
1244 .expect("LazyColumn provides a bring-into-view responder to its items");
1245 let list_state = state_slot.borrow().expect("list state captured");
1246 let offset0 = list_state.first_visible_item_scroll_offset();
1247 let index0 = list_state.first_visible_item_index();
1248
1249 responder.bring_into_view(
1252 Rect {
1253 x: 10.0,
1254 y: 100.0,
1255 width: 2.0,
1256 height: 20.0,
1257 },
1258 0.0,
1259 );
1260 run_layout(&mut composition, key, &mut content);
1261 assert_eq!(
1262 list_state.first_visible_item_index(),
1263 index0,
1264 "an already-visible caret must not scroll the list"
1265 );
1266 assert!(
1267 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1268 "an already-visible caret must not scroll the list"
1269 );
1270
1271 responder.bring_into_view(
1274 Rect {
1275 x: 10.0,
1276 y: 360.0,
1277 width: 2.0,
1278 height: 20.0,
1279 },
1280 250.0,
1281 );
1282 run_layout(&mut composition, key, &mut content);
1283 let scrolled_forward = list_state.first_visible_item_index() > index0
1284 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1285 assert!(
1286 scrolled_forward,
1287 "a caret behind the keyboard must scroll the list forward \
1288 (index {} -> {}, offset {:.1} -> {:.1})",
1289 index0,
1290 list_state.first_visible_item_index(),
1291 offset0,
1292 list_state.first_visible_item_scroll_offset(),
1293 );
1294 }
1295}