1#![allow(non_snake_case)]
7
8use crate::clipboard_session::{clipboard_read_text, clipboard_write_text};
9use crate::composable;
10use crate::layout::policies::EmptyMeasurePolicy;
11use crate::modifier::Modifier;
12use crate::text::{measure_text, AnnotatedString, TextStyle};
13use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_paste, dispatch_select_all};
14use crate::text_field_modifier_node::{
15 TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
16};
17use crate::text_selection::{selection_after_handle_drag, HandleKind, HANDLE_RADIUS};
18use crate::widgets::{CursorMagnifier, Layout, SelectionHandle, TextSelectionMenu};
19use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
20use cranpose_foundation::modifier_element;
21use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
22use cranpose_ui_graphics::{Color, Point};
23use std::cell::Cell;
24use std::rc::Rc;
25
26const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);
28
29fn handle_tip_window_pos(
32 text: &str,
33 style: &TextStyle,
34 metrics: &TextFieldHandleMetrics,
35 offset: usize,
36) -> Point {
37 let offset = offset.min(text.len());
38 let before = &text[..offset];
39 let line_index = before.matches('\n').count();
40 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
41 let caret_x = measure_text(&AnnotatedString::from(&before[line_start..]), style).width;
42 Point {
43 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
44 y: metrics.node_origin.y
45 + metrics.padding_top
46 + (line_index as f32 + 1.0) * metrics.line_height,
47 }
48}
49
50fn window_pos_to_offset(
53 text: &str,
54 style: &TextStyle,
55 metrics: &TextFieldHandleMetrics,
56 window_pos: Point,
57) -> usize {
58 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
59 + metrics.scroll_offset)
60 .max(0.0);
61 let local_y =
64 (window_pos.y - metrics.node_origin.y - metrics.padding_top - metrics.line_height).max(0.0);
65 crate::text::get_offset_for_position(&AnnotatedString::from(text), style, local_x, local_y)
66}
67#[composable]
85pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
86 BasicTextFieldWithOptions(
87 state,
88 modifier,
89 BasicTextFieldOptions {
90 text_style: style,
91 ..BasicTextFieldOptions::default()
92 },
93 )
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct BasicTextFieldOptions {
99 pub text_style: TextStyle,
101 pub cursor_color: Color,
103 pub line_limits: TextFieldLineLimits,
105}
106
107impl Default for BasicTextFieldOptions {
108 fn default() -> Self {
109 Self {
110 text_style: TextStyle::default(),
111 cursor_color: Color(0.0, 0.0, 0.0, 1.0), line_limits: TextFieldLineLimits::default(),
113 }
114 }
115}
116
117#[composable]
121pub fn BasicTextFieldWithOptions(
122 state: TextFieldState,
123 modifier: Modifier,
124 options: BasicTextFieldOptions,
125) -> NodeId {
126 let _text = state.text();
129 let _selection = state.selection();
130
131 let controller =
135 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
136
137 let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
139 .with_cursor_color(options.cursor_color)
140 .with_line_limits(options.line_limits)
141 .with_handle_controller(controller.clone());
142
143 let text_field_modifier = modifier_element(text_field_element);
145 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
146 let combined_modifier = modifier.then(final_modifier);
147
148 let node = Layout(
151 combined_modifier,
152 EmptyMeasurePolicy,
153 || {}, );
155
156 SelectionHandles(state, options.text_style, controller);
162
163 node
164}
165
166#[composable]
169fn SelectionHandles(
170 state: TextFieldState,
171 style: TextStyle,
172 controller: TextFieldHandleController,
173) {
174 let selection = state.selection();
175 let current_range = (selection.min(), selection.max());
176
177 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
181 let previous_range: Rc<Cell<(usize, usize)>> =
182 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
183 {
184 let previous_range = Rc::clone(&previous_range);
185 SideEffect(move || {
186 if previous_range.get() != current_range {
187 previous_range.set(current_range);
188 menu_open.set(true);
189 }
190 });
191 }
192
193 let Some(metrics) = controller.metrics() else {
194 return;
195 };
196 if !metrics.focused || !metrics.touch {
197 return;
198 }
199
200 let text = state.text();
201
202 let drag_pos: MutableState<Option<Point>> =
206 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
207
208 if selection.collapsed() {
209 let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
211 let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
212 SelectionHandle(
213 HandleKind::Cursor,
214 tip,
215 HANDLE_RADIUS,
216 HANDLE_COLOR,
217 move |pos| {
218 drag_pos.set(Some(pos));
219 on_drag(pos);
220 },
221 move || drag_pos.set(None),
222 || {},
223 );
224 } else {
225 let start = selection.min();
227 let end = selection.max();
228 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
229 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
230
231 let on_drag_start = drag_edge_closure(
232 HandleKind::SelectionStart,
233 state.clone(),
234 style.clone(),
235 controller.clone(),
236 );
237 SelectionHandle(
238 HandleKind::SelectionStart,
239 start_tip,
240 HANDLE_RADIUS,
241 HANDLE_COLOR,
242 move |pos| {
243 drag_pos.set(Some(pos));
244 on_drag_start(pos);
245 },
246 move || drag_pos.set(None),
247 move || menu_open.set(true),
251 );
252
253 let on_drag_end = drag_edge_closure(
254 HandleKind::SelectionEnd,
255 state.clone(),
256 style.clone(),
257 controller.clone(),
258 );
259 SelectionHandle(
260 HandleKind::SelectionEnd,
261 end_tip,
262 HANDLE_RADIUS,
263 HANDLE_COLOR,
264 move |pos| {
265 drag_pos.set(Some(pos));
266 on_drag_end(pos);
267 },
268 move || drag_pos.set(None),
269 move || menu_open.set(true),
270 );
271
272 if menu_open.value() {
275 let menu_anchor = Point {
277 x: (start_tip.x + end_tip.x) * 0.5,
278 y: start_tip.y - metrics.line_height,
279 };
280 let can_paste = clipboard_read_text().is_some();
281 TextSelectionMenu(
282 menu_anchor,
283 can_paste,
284 move || {
285 if let Some(text) = dispatch_copy() {
286 clipboard_write_text(&text);
287 }
288 menu_open.set(false);
289 },
290 move || {
291 if let Some(text) = dispatch_cut() {
292 clipboard_write_text(&text);
293 }
294 menu_open.set(false);
295 },
296 move || {
297 if let Some(text) = clipboard_read_text() {
298 dispatch_paste(&text);
299 }
300 menu_open.set(false);
301 },
302 move || {
303 dispatch_select_all();
304 menu_open.set(false);
305 },
306 );
307 }
308 }
309
310 if let Some(finger) = drag_pos.value() {
317 let offset = window_pos_to_offset(&text, &style, &metrics, finger);
318 CursorMagnifier(text.clone(), style.clone(), metrics, offset, finger);
319 }
320}
321
322fn drag_caret_closure(
325 state: TextFieldState,
326 style: TextStyle,
327 controller: TextFieldHandleController,
328) -> Rc<dyn Fn(Point)> {
329 Rc::new(move |window_pos: Point| {
330 let Some(metrics) = controller.metrics() else {
331 return;
332 };
333 let text = state.text();
334 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
335 state.set_selection(TextRange::new(offset, offset));
336 crate::request_render_invalidation();
337 })
338}
339
340fn drag_edge_closure(
344 dragged: HandleKind,
345 state: TextFieldState,
346 style: TextStyle,
347 controller: TextFieldHandleController,
348) -> Rc<dyn Fn(Point)> {
349 Rc::new(move |window_pos: Point| {
350 let Some(metrics) = controller.metrics() else {
351 return;
352 };
353 let text = state.text();
354 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
355 let selection = state.selection();
356 let fixed_edge = match dragged {
357 HandleKind::SelectionStart => selection.max(),
358 _ => selection.min(),
359 };
360 let (min, max) =
361 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
362 state.set_selection(TextRange::new(min, max));
363 crate::request_render_invalidation();
364 })
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
371 use std::sync::Arc;
372
373 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
375 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
376 f()
377 }
378
379 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
383 use crate::layout::LayoutEngine;
384 use crate::renderer::HeadlessRenderer;
385 use crate::widgets::PopupHost;
386 use cranpose_ui_graphics::Size;
387
388 let mut composition = Composition::new(MemoryApplier::new());
389 let key = location_key(file!(), line!(), column!());
390 let state = TextFieldState::new("hello world");
391
392 let mut content = {
393 let state = state.clone();
394 move || {
395 let state = state.clone();
396 PopupHost(move || {
397 let controller = TextFieldHandleController::new();
398 controller.publish(TextFieldHandleMetrics {
399 focused: true,
400 touch,
401 node_origin: Point { x: 0.0, y: 10.0 },
402 padding_left: 0.0,
403 padding_top: 0.0,
404 scroll_offset: 0.0,
405 line_height: 18.0,
406 });
407 SelectionHandles(state.clone(), TextStyle::default(), controller);
408 });
409 }
410 };
411
412 composition.render(key, &mut content).expect("render");
413 for _ in 0..16 {
414 if !composition.should_render() {
415 break;
416 }
417 composition.reconcile(key, &mut content).expect("reconcile");
418 }
419 let root = composition.root().expect("root");
420 let handle = composition.runtime_handle();
421 let mut applier = composition.applier_mut();
422 applier.set_runtime_handle(handle);
423 let layout = applier
424 .compute_layout(
425 root,
426 Size {
427 width: 400.0,
428 height: 400.0,
429 },
430 )
431 .expect("layout");
432 applier.clear_runtime_handle();
433 drop(applier);
434 HeadlessRenderer::new().render(&layout)
435 }
436
437 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
440 use crate::layout::LayoutEngine;
441 use crate::renderer::HeadlessRenderer;
442 use crate::widgets::PopupHost;
443 use cranpose_ui_graphics::Size;
444
445 let mut composition = Composition::new(MemoryApplier::new());
446 let key = location_key(file!(), line!(), column!());
447 let state = TextFieldState::new("hello world");
448
449 let mut content = {
450 let state = state.clone();
451 move || {
452 let state = state.clone();
453 PopupHost(move || {
454 let controller = TextFieldHandleController::new();
455 if state.selection() != TextRange::new(0, 5) {
456 state.set_selection(TextRange::new(0, 5));
457 }
458 controller.publish(TextFieldHandleMetrics {
459 focused: true,
460 touch,
461 node_origin: Point { x: 0.0, y: 40.0 },
462 padding_left: 0.0,
463 padding_top: 0.0,
464 scroll_offset: 0.0,
465 line_height: 18.0,
466 });
467 SelectionHandles(state.clone(), TextStyle::default(), controller);
468 });
469 }
470 };
471
472 composition.render(key, &mut content).expect("render");
473 for _ in 0..16 {
474 if !composition.should_render() {
475 break;
476 }
477 composition.reconcile(key, &mut content).expect("reconcile");
478 }
479 let root = composition.root().expect("root");
480 let handle = composition.runtime_handle();
481 let mut applier = composition.applier_mut();
482 applier.set_runtime_handle(handle);
483 let layout = applier
484 .compute_layout(
485 root,
486 Size {
487 width: 400.0,
488 height: 400.0,
489 },
490 )
491 .expect("layout");
492 applier.clear_runtime_handle();
493 drop(applier);
494 HeadlessRenderer::new().render(&layout)
495 }
496
497 fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
503 use crate::layout::LayoutEngine;
504 use crate::renderer::HeadlessRenderer;
505 use crate::widgets::{BoxWithConstraints, PopupHost};
506 use cranpose_ui_graphics::Size;
507
508 let mut composition = Composition::new(MemoryApplier::new());
509 let key = location_key(file!(), line!(), column!());
510 let state = TextFieldState::new("hello world");
511
512 let mut content = {
513 let state = state.clone();
514 move || {
515 let state = state.clone();
516 PopupHost(move || {
517 let state = state.clone();
518 BoxWithConstraints(
519 Modifier::empty().size(Size {
520 width: 300.0,
521 height: 300.0,
522 }),
523 move |_scope| {
524 let controller = TextFieldHandleController::new();
525 if state.selection() != TextRange::new(0, 5) {
526 state.set_selection(TextRange::new(0, 5));
527 }
528 controller.publish(TextFieldHandleMetrics {
529 focused: true,
530 touch,
531 node_origin: Point { x: 0.0, y: 40.0 },
532 padding_left: 0.0,
533 padding_top: 0.0,
534 scroll_offset: 0.0,
535 line_height: 18.0,
536 });
537 SelectionHandles(state.clone(), TextStyle::default(), controller);
538 },
539 );
540 });
541 }
542 };
543
544 composition.render(key, &mut content).expect("render");
545 let root = composition.root().expect("root");
546 let handle = composition.runtime_handle();
547 let mut scene = None;
548 for _ in 0..8 {
552 for _ in 0..16 {
553 if !composition.should_render() {
554 break;
555 }
556 composition.reconcile(key, &mut content).expect("reconcile");
557 }
558 let mut applier = composition.applier_mut();
559 applier.set_runtime_handle(handle.clone());
560 let layout = applier
561 .compute_layout(
562 root,
563 Size {
564 width: 400.0,
565 height: 400.0,
566 },
567 )
568 .expect("layout");
569 applier.clear_runtime_handle();
570 drop(applier);
571 scene = Some(HeadlessRenderer::new().render(&layout));
572 }
573 scene.expect("scene")
574 }
575
576 fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
583 use crate::layout::LayoutEngine;
584 use crate::renderer::HeadlessRenderer;
585 use crate::widgets::PopupHost;
586 use crate::{LazyColumn, LazyColumnSpec};
587 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
588 use cranpose_ui_graphics::Size;
589
590 let mut composition = Composition::new(MemoryApplier::new());
591 let key = location_key(file!(), line!(), column!());
592 let state = TextFieldState::new("hello world");
593
594 let mut content = {
595 let state = state.clone();
596 move || {
597 let state = state.clone();
598 PopupHost(move || {
599 let state = state.clone();
600 let list_state = remember_lazy_list_state();
601 LazyColumn(
602 Modifier::empty().size(Size {
603 width: 300.0,
604 height: 300.0,
605 }),
606 list_state,
607 LazyColumnSpec::default(),
608 move |scope| {
609 let state = state.clone();
610 scope.items(
611 1,
612 None::<fn(usize) -> u64>,
613 None::<fn(usize) -> u64>,
614 move |_index| {
615 let controller = TextFieldHandleController::new();
616 if state.selection() != TextRange::new(0, 5) {
617 state.set_selection(TextRange::new(0, 5));
618 }
619 controller.publish(TextFieldHandleMetrics {
620 focused: true,
621 touch,
622 node_origin: Point { x: 0.0, y: 40.0 },
623 padding_left: 0.0,
624 padding_top: 0.0,
625 scroll_offset: 0.0,
626 line_height: 18.0,
627 });
628 SelectionHandles(
629 state.clone(),
630 TextStyle::default(),
631 controller,
632 );
633 },
634 );
635 },
636 );
637 });
638 }
639 };
640
641 composition.render(key, &mut content).expect("render");
642 let root = composition.root().expect("root");
643 let handle = composition.runtime_handle();
644 let mut scene = None;
645 for _ in 0..8 {
646 for _ in 0..16 {
647 if !composition.should_render() {
648 break;
649 }
650 composition.reconcile(key, &mut content).expect("reconcile");
651 }
652 let mut applier = composition.applier_mut();
653 applier.set_runtime_handle(handle.clone());
654 let layout = applier
655 .compute_layout(
656 root,
657 Size {
658 width: 400.0,
659 height: 400.0,
660 },
661 )
662 .expect("layout");
663 applier.clear_runtime_handle();
664 drop(applier);
665 scene = Some(HeadlessRenderer::new().render(&layout));
666 }
667 scene.expect("scene")
668 }
669
670 #[test]
679 fn field_window_origin_follows_vertical_scroll() {
680 use crate::layout::policies::EmptyMeasurePolicy;
681 use crate::layout::{LayoutBox, LayoutEngine};
682 use crate::renderer::HeadlessRenderer;
683 use crate::scroll::ScrollState;
684 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
685 use cranpose_core::{remember, Key};
686 use cranpose_foundation::modifier_element;
687 use cranpose_ui_graphics::Size;
688 use std::cell::RefCell;
689
690 let _app_context = crate::render_state::app_context_test_scope();
691
692 let mut composition = Composition::new(MemoryApplier::new());
695 let state = TextFieldState::new("hello world");
696 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
697 Rc::new(RefCell::new(None));
698 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
701
702 let spacer_before = 200.0_f32;
703 let mut content = {
704 let state = state.clone();
705 let controller_slot = Rc::clone(&controller_slot);
706 let scroll_slot = Rc::clone(&scroll_slot);
707 move || {
708 let state = state.clone();
709 let controller_slot = Rc::clone(&controller_slot);
710 let scroll_slot = Rc::clone(&scroll_slot);
711 PopupHost(move || {
712 let controller = remember(TextFieldHandleController::new)
713 .with(TextFieldHandleController::clone);
714 *controller_slot.borrow_mut() = Some(controller.clone());
715 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
716 *scroll_slot.borrow_mut() = Some(scroll.clone());
717 let state = state.clone();
718 let controller = controller.clone();
719 Column(
720 Modifier::empty()
721 .size(Size {
722 width: 300.0,
723 height: 150.0,
724 })
725 .vertical_scroll(scroll.clone(), false),
726 ColumnSpec::default(),
727 move || {
728 Spacer(Size {
729 width: 300.0,
730 height: spacer_before,
731 });
732 let element =
733 TextFieldElement::new(state.clone(), TextStyle::default())
734 .with_handle_controller(controller.clone());
735 let field_modifier =
736 Modifier::from_parts(vec![modifier_element(element)]);
737 Layout(field_modifier, EmptyMeasurePolicy, || {});
738 Spacer(Size {
739 width: 300.0,
740 height: 400.0,
741 });
742 },
743 );
744 });
745 }
746 };
747
748 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
750 if node
751 .node_data
752 .modifier_slices()
753 .text_field_window_origin()
754 .is_some()
755 {
756 return Some(node.rect);
757 }
758 node.children.iter().find_map(find_field_rect)
759 }
760
761 fn layout_and_read(
762 composition: &mut Composition<MemoryApplier>,
763 key: Key,
764 content: &mut dyn FnMut(),
765 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
766 ) -> (Point, f32) {
767 for _ in 0..16 {
768 if !composition.should_render() {
769 break;
770 }
771 composition
772 .reconcile(key, &mut *content)
773 .expect("reconcile");
774 }
775 let root = composition.root().expect("root");
776 let handle = composition.runtime_handle();
777 let mut applier = composition.applier_mut();
778 applier.set_runtime_handle(handle);
779 let layout = applier
780 .compute_layout(
781 root,
782 cranpose_ui_graphics::Size {
783 width: 400.0,
784 height: 600.0,
785 },
786 )
787 .expect("layout");
788 applier.clear_runtime_handle();
789 drop(applier);
790 let _ = HeadlessRenderer::new().render(&layout);
791 let field_y = find_field_rect(layout.root()).expect("field placed").y;
792 let node_origin = controller_slot
793 .borrow()
794 .as_ref()
795 .expect("controller")
796 .metrics()
797 .expect("metrics published")
798 .node_origin;
799 (node_origin, field_y)
800 }
801
802 let key = location_key(file!(), line!(), column!());
803 composition.render(key, &mut content).expect("render");
804
805 let (origin0, field_y0) =
806 layout_and_read(&mut composition, key, &mut content, &controller_slot);
807 assert!(
810 (origin0.y - field_y0).abs() < 0.5,
811 "published node_origin.y {} must equal the field's placed window-y {}",
812 origin0.y,
813 field_y0
814 );
815 assert!(
816 origin0.y >= spacer_before - 0.5,
817 "field should start at/after the {spacer_before}px leading spacer, got {}",
818 origin0.y
819 );
820
821 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
824 scroll.scroll_to(50.0);
825 assert!(
826 scroll.value() >= 49.5,
827 "test setup: content must be tall enough to scroll 50px (got {})",
828 scroll.value()
829 );
830 let (origin1, field_y1) =
831 layout_and_read(&mut composition, key, &mut content, &controller_slot);
832 assert!(
833 (origin1.y - field_y1).abs() < 0.5,
834 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
835 origin1.y,
836 field_y1
837 );
838 assert!(
839 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
840 "scrolling 50px must shift the published field origin up by 50px: \
841 before {}, after {} (expected {})",
842 origin0.y,
843 origin1.y,
844 origin0.y - 50.0
845 );
846 }
847
848 #[test]
854 fn window_offset_roundtrip_holds_under_scroll_offset() {
855 let _app_context = crate::render_state::app_context_test_scope();
856 let text = "hello world";
857 let style = TextStyle::default();
858 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
860 let metrics = TextFieldHandleMetrics {
861 focused: true,
862 touch: true,
863 node_origin,
864 padding_left: 4.0,
865 padding_top: 3.0,
866 scroll_offset: 0.0,
867 line_height: 18.0,
868 };
869 for offset in 0..=text.len() {
870 if !text.is_char_boundary(offset) {
871 continue;
872 }
873 let tip = handle_tip_window_pos(text, &style, &metrics, offset);
874 let resolved = window_pos_to_offset(text, &style, &metrics, tip);
875 assert_eq!(
876 resolved, offset,
877 "finger at the tip of offset {offset} must map back to it \
878 under origin {node_origin:?}, got {resolved}"
879 );
880 }
881 }
882 }
883
884 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
885 use crate::renderer::RenderOp;
886 scene
887 .operations()
888 .iter()
889 .filter_map(|op| match op {
890 RenderOp::Text { value, .. } => Some(value.clone()),
891 _ => None,
892 })
893 .collect()
894 }
895
896 #[test]
897 fn context_menu_shows_for_touch_selection_only() {
898 let _app_context = crate::render_state::app_context_test_scope();
899
900 let touch = text_values(&render_range_menu(true));
901 assert!(
902 touch.iter().any(|t| t == "Copy"),
903 "touch selection should show the Copy menu item, got {touch:?}"
904 );
905 assert!(
906 touch.iter().any(|t| t == "Cut"),
907 "expected Cut, got {touch:?}"
908 );
909 assert!(
910 touch.iter().any(|t| t == "Select all"),
911 "expected Select all, got {touch:?}"
912 );
913
914 let mouse = text_values(&render_range_menu(false));
915 assert!(
916 !mouse.iter().any(|t| t == "Copy"),
917 "mouse selection must not show the finger contextual menu, got {mouse:?}"
918 );
919 }
920
921 #[test]
922 fn selection_handles_and_menu_survive_subcomposition() {
923 let _app_context = crate::render_state::app_context_test_scope();
929 let scene = render_range_menu_subcomposed(true);
930
931 let texts = text_values(&scene);
932 assert!(
933 texts.iter().any(|t| t == "Copy"),
934 "a touch selection inside a subcomposition should show the Copy menu \
935 item through the host, got {texts:?}"
936 );
937 assert!(
938 texts.iter().any(|t| t == "Select all"),
939 "expected Select all inside a subcomposition, got {texts:?}"
940 );
941 assert_eq!(
942 image_count(&scene),
943 2,
944 "a touch range selection should show two finger teardrop handles in \
945 the overlay across the subcomposition boundary"
946 );
947 }
948
949 #[test]
950 fn selection_handles_and_menu_survive_lazy_column_item() {
951 let _app_context = crate::render_state::app_context_test_scope();
958 let scene = render_range_menu_lazy_column(true);
959
960 let texts = text_values(&scene);
961 assert!(
962 texts.iter().any(|t| t == "Copy"),
963 "a touch selection inside a LazyColumn item should show the Copy menu \
964 item through the host, got {texts:?}"
965 );
966 assert!(
967 texts.iter().any(|t| t == "Select all"),
968 "expected Select all inside a LazyColumn item, got {texts:?}"
969 );
970 assert_eq!(
971 image_count(&scene),
972 2,
973 "a touch range selection should show two finger teardrop handles in \
974 the overlay across the LazyColumn item subcomposition boundary"
975 );
976 }
977
978 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
979 use crate::renderer::RenderOp;
980 use cranpose_ui_graphics::DrawPrimitive;
981 scene
982 .operations()
983 .iter()
984 .filter(|op| {
985 matches!(
986 op,
987 RenderOp::Primitive {
988 primitive: DrawPrimitive::Image { .. },
989 ..
990 }
991 )
992 })
993 .count()
994 }
995
996 #[test]
997 fn cursor_handle_shows_for_touch_only() {
998 let _app_context = crate::render_state::app_context_test_scope();
999 assert_eq!(
1000 image_count(&render_collapsed_handles(true)),
1001 1,
1002 "a touch caret should show one finger cursor handle in the overlay"
1003 );
1004 assert_eq!(
1005 image_count(&render_collapsed_handles(false)),
1006 0,
1007 "a mouse caret should keep a clean caret with no finger handle"
1008 );
1009 }
1010
1011 #[test]
1012 fn basic_text_field_creates_node() {
1013 let _app_context = crate::render_state::app_context_test_scope();
1014 let mut composition = Composition::new(MemoryApplier::new());
1015 let state = TextFieldState::new("Test content");
1016
1017 let result = composition.render(location_key(file!(), line!(), column!()), {
1018 let state = state.clone();
1019 move || {
1020 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1021 }
1022 });
1023
1024 assert!(result.is_ok());
1025 assert!(composition.root().is_some());
1026 }
1027
1028 #[test]
1029 fn basic_text_field_state_updates() {
1030 let _app_context = crate::render_state::app_context_test_scope();
1031 with_test_runtime(|| {
1032 let state = TextFieldState::new("Hello");
1033 assert_eq!(state.text(), "Hello");
1034
1035 state.edit(|buffer| {
1036 buffer.place_cursor_at_end();
1037 buffer.insert("!");
1038 });
1039
1040 assert_eq!(state.text(), "Hello!");
1041 });
1042 }
1043}