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::{Layout, SelectionHandle, TextSelectionMenu};
19use cranpose_core::{mutableStateOf, remember, 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 if selection.collapsed() {
203 let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
205 let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
206 SelectionHandle(
207 HandleKind::Cursor,
208 tip,
209 HANDLE_RADIUS,
210 HANDLE_COLOR,
211 move |pos| on_drag(pos),
212 || {},
213 || {},
214 );
215 } else {
216 let start = selection.min();
218 let end = selection.max();
219 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
220 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
221
222 let on_drag_start = drag_edge_closure(
223 HandleKind::SelectionStart,
224 state.clone(),
225 style.clone(),
226 controller.clone(),
227 );
228 SelectionHandle(
229 HandleKind::SelectionStart,
230 start_tip,
231 HANDLE_RADIUS,
232 HANDLE_COLOR,
233 move |pos| on_drag_start(pos),
234 || {},
235 move || menu_open.set(true),
239 );
240
241 let on_drag_end = drag_edge_closure(
242 HandleKind::SelectionEnd,
243 state.clone(),
244 style.clone(),
245 controller.clone(),
246 );
247 SelectionHandle(
248 HandleKind::SelectionEnd,
249 end_tip,
250 HANDLE_RADIUS,
251 HANDLE_COLOR,
252 move |pos| on_drag_end(pos),
253 || {},
254 move || menu_open.set(true),
255 );
256
257 if menu_open.value() {
260 let menu_anchor = Point {
262 x: (start_tip.x + end_tip.x) * 0.5,
263 y: start_tip.y - metrics.line_height,
264 };
265 let can_paste = clipboard_read_text().is_some();
266 TextSelectionMenu(
267 menu_anchor,
268 can_paste,
269 move || {
270 if let Some(text) = dispatch_copy() {
271 clipboard_write_text(&text);
272 }
273 menu_open.set(false);
274 },
275 move || {
276 if let Some(text) = dispatch_cut() {
277 clipboard_write_text(&text);
278 }
279 menu_open.set(false);
280 },
281 move || {
282 if let Some(text) = clipboard_read_text() {
283 dispatch_paste(&text);
284 }
285 menu_open.set(false);
286 },
287 move || {
288 dispatch_select_all();
289 menu_open.set(false);
290 },
291 );
292 }
293 }
294}
295
296fn drag_caret_closure(
299 state: TextFieldState,
300 style: TextStyle,
301 controller: TextFieldHandleController,
302) -> Rc<dyn Fn(Point)> {
303 Rc::new(move |window_pos: Point| {
304 let Some(metrics) = controller.metrics() else {
305 return;
306 };
307 let text = state.text();
308 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
309 state.set_selection(TextRange::new(offset, offset));
310 crate::request_render_invalidation();
311 })
312}
313
314fn drag_edge_closure(
318 dragged: HandleKind,
319 state: TextFieldState,
320 style: TextStyle,
321 controller: TextFieldHandleController,
322) -> Rc<dyn Fn(Point)> {
323 Rc::new(move |window_pos: Point| {
324 let Some(metrics) = controller.metrics() else {
325 return;
326 };
327 let text = state.text();
328 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
329 let selection = state.selection();
330 let fixed_edge = match dragged {
331 HandleKind::SelectionStart => selection.max(),
332 _ => selection.min(),
333 };
334 let (min, max) =
335 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
336 state.set_selection(TextRange::new(min, max));
337 crate::request_render_invalidation();
338 })
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
345 use std::sync::Arc;
346
347 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
349 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
350 f()
351 }
352
353 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
357 use crate::layout::LayoutEngine;
358 use crate::renderer::HeadlessRenderer;
359 use crate::widgets::PopupHost;
360 use cranpose_ui_graphics::Size;
361
362 let mut composition = Composition::new(MemoryApplier::new());
363 let key = location_key(file!(), line!(), column!());
364 let state = TextFieldState::new("hello world");
365
366 let mut content = {
367 let state = state.clone();
368 move || {
369 let state = state.clone();
370 PopupHost(move || {
371 let controller = TextFieldHandleController::new();
372 controller.publish(TextFieldHandleMetrics {
373 focused: true,
374 touch,
375 node_origin: Point { x: 0.0, y: 10.0 },
376 padding_left: 0.0,
377 padding_top: 0.0,
378 scroll_offset: 0.0,
379 line_height: 18.0,
380 });
381 SelectionHandles(state.clone(), TextStyle::default(), controller);
382 });
383 }
384 };
385
386 composition.render(key, &mut content).expect("render");
387 for _ in 0..16 {
388 if !composition.should_render() {
389 break;
390 }
391 composition.reconcile(key, &mut content).expect("reconcile");
392 }
393 let root = composition.root().expect("root");
394 let handle = composition.runtime_handle();
395 let mut applier = composition.applier_mut();
396 applier.set_runtime_handle(handle);
397 let layout = applier
398 .compute_layout(
399 root,
400 Size {
401 width: 400.0,
402 height: 400.0,
403 },
404 )
405 .expect("layout");
406 applier.clear_runtime_handle();
407 drop(applier);
408 HeadlessRenderer::new().render(&layout)
409 }
410
411 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
414 use crate::layout::LayoutEngine;
415 use crate::renderer::HeadlessRenderer;
416 use crate::widgets::PopupHost;
417 use cranpose_ui_graphics::Size;
418
419 let mut composition = Composition::new(MemoryApplier::new());
420 let key = location_key(file!(), line!(), column!());
421 let state = TextFieldState::new("hello world");
422
423 let mut content = {
424 let state = state.clone();
425 move || {
426 let state = state.clone();
427 PopupHost(move || {
428 let controller = TextFieldHandleController::new();
429 if state.selection() != TextRange::new(0, 5) {
430 state.set_selection(TextRange::new(0, 5));
431 }
432 controller.publish(TextFieldHandleMetrics {
433 focused: true,
434 touch,
435 node_origin: Point { x: 0.0, y: 40.0 },
436 padding_left: 0.0,
437 padding_top: 0.0,
438 scroll_offset: 0.0,
439 line_height: 18.0,
440 });
441 SelectionHandles(state.clone(), TextStyle::default(), controller);
442 });
443 }
444 };
445
446 composition.render(key, &mut content).expect("render");
447 for _ in 0..16 {
448 if !composition.should_render() {
449 break;
450 }
451 composition.reconcile(key, &mut content).expect("reconcile");
452 }
453 let root = composition.root().expect("root");
454 let handle = composition.runtime_handle();
455 let mut applier = composition.applier_mut();
456 applier.set_runtime_handle(handle);
457 let layout = applier
458 .compute_layout(
459 root,
460 Size {
461 width: 400.0,
462 height: 400.0,
463 },
464 )
465 .expect("layout");
466 applier.clear_runtime_handle();
467 drop(applier);
468 HeadlessRenderer::new().render(&layout)
469 }
470
471 fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
477 use crate::layout::LayoutEngine;
478 use crate::renderer::HeadlessRenderer;
479 use crate::widgets::{BoxWithConstraints, PopupHost};
480 use cranpose_ui_graphics::Size;
481
482 let mut composition = Composition::new(MemoryApplier::new());
483 let key = location_key(file!(), line!(), column!());
484 let state = TextFieldState::new("hello world");
485
486 let mut content = {
487 let state = state.clone();
488 move || {
489 let state = state.clone();
490 PopupHost(move || {
491 let state = state.clone();
492 BoxWithConstraints(
493 Modifier::empty().size(Size {
494 width: 300.0,
495 height: 300.0,
496 }),
497 move |_scope| {
498 let controller = TextFieldHandleController::new();
499 if state.selection() != TextRange::new(0, 5) {
500 state.set_selection(TextRange::new(0, 5));
501 }
502 controller.publish(TextFieldHandleMetrics {
503 focused: true,
504 touch,
505 node_origin: Point { x: 0.0, y: 40.0 },
506 padding_left: 0.0,
507 padding_top: 0.0,
508 scroll_offset: 0.0,
509 line_height: 18.0,
510 });
511 SelectionHandles(state.clone(), TextStyle::default(), controller);
512 },
513 );
514 });
515 }
516 };
517
518 composition.render(key, &mut content).expect("render");
519 let root = composition.root().expect("root");
520 let handle = composition.runtime_handle();
521 let mut scene = None;
522 for _ in 0..8 {
526 for _ in 0..16 {
527 if !composition.should_render() {
528 break;
529 }
530 composition.reconcile(key, &mut content).expect("reconcile");
531 }
532 let mut applier = composition.applier_mut();
533 applier.set_runtime_handle(handle.clone());
534 let layout = applier
535 .compute_layout(
536 root,
537 Size {
538 width: 400.0,
539 height: 400.0,
540 },
541 )
542 .expect("layout");
543 applier.clear_runtime_handle();
544 drop(applier);
545 scene = Some(HeadlessRenderer::new().render(&layout));
546 }
547 scene.expect("scene")
548 }
549
550 fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
557 use crate::layout::LayoutEngine;
558 use crate::renderer::HeadlessRenderer;
559 use crate::widgets::PopupHost;
560 use crate::{LazyColumn, LazyColumnSpec};
561 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
562 use cranpose_ui_graphics::Size;
563
564 let mut composition = Composition::new(MemoryApplier::new());
565 let key = location_key(file!(), line!(), column!());
566 let state = TextFieldState::new("hello world");
567
568 let mut content = {
569 let state = state.clone();
570 move || {
571 let state = state.clone();
572 PopupHost(move || {
573 let state = state.clone();
574 let list_state = remember_lazy_list_state();
575 LazyColumn(
576 Modifier::empty().size(Size {
577 width: 300.0,
578 height: 300.0,
579 }),
580 list_state,
581 LazyColumnSpec::default(),
582 move |scope| {
583 let state = state.clone();
584 scope.items(
585 1,
586 None::<fn(usize) -> u64>,
587 None::<fn(usize) -> u64>,
588 move |_index| {
589 let controller = TextFieldHandleController::new();
590 if state.selection() != TextRange::new(0, 5) {
591 state.set_selection(TextRange::new(0, 5));
592 }
593 controller.publish(TextFieldHandleMetrics {
594 focused: true,
595 touch,
596 node_origin: Point { x: 0.0, y: 40.0 },
597 padding_left: 0.0,
598 padding_top: 0.0,
599 scroll_offset: 0.0,
600 line_height: 18.0,
601 });
602 SelectionHandles(
603 state.clone(),
604 TextStyle::default(),
605 controller,
606 );
607 },
608 );
609 },
610 );
611 });
612 }
613 };
614
615 composition.render(key, &mut content).expect("render");
616 let root = composition.root().expect("root");
617 let handle = composition.runtime_handle();
618 let mut scene = None;
619 for _ in 0..8 {
620 for _ in 0..16 {
621 if !composition.should_render() {
622 break;
623 }
624 composition.reconcile(key, &mut content).expect("reconcile");
625 }
626 let mut applier = composition.applier_mut();
627 applier.set_runtime_handle(handle.clone());
628 let layout = applier
629 .compute_layout(
630 root,
631 Size {
632 width: 400.0,
633 height: 400.0,
634 },
635 )
636 .expect("layout");
637 applier.clear_runtime_handle();
638 drop(applier);
639 scene = Some(HeadlessRenderer::new().render(&layout));
640 }
641 scene.expect("scene")
642 }
643
644 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
645 use crate::renderer::RenderOp;
646 scene
647 .operations()
648 .iter()
649 .filter_map(|op| match op {
650 RenderOp::Text { value, .. } => Some(value.clone()),
651 _ => None,
652 })
653 .collect()
654 }
655
656 #[test]
657 fn context_menu_shows_for_touch_selection_only() {
658 let _app_context = crate::render_state::app_context_test_scope();
659
660 let touch = text_values(&render_range_menu(true));
661 assert!(
662 touch.iter().any(|t| t == "Copy"),
663 "touch selection should show the Copy menu item, got {touch:?}"
664 );
665 assert!(
666 touch.iter().any(|t| t == "Cut"),
667 "expected Cut, got {touch:?}"
668 );
669 assert!(
670 touch.iter().any(|t| t == "Select all"),
671 "expected Select all, got {touch:?}"
672 );
673
674 let mouse = text_values(&render_range_menu(false));
675 assert!(
676 !mouse.iter().any(|t| t == "Copy"),
677 "mouse selection must not show the finger contextual menu, got {mouse:?}"
678 );
679 }
680
681 #[test]
682 fn selection_handles_and_menu_survive_subcomposition() {
683 let _app_context = crate::render_state::app_context_test_scope();
689 let scene = render_range_menu_subcomposed(true);
690
691 let texts = text_values(&scene);
692 assert!(
693 texts.iter().any(|t| t == "Copy"),
694 "a touch selection inside a subcomposition should show the Copy menu \
695 item through the host, got {texts:?}"
696 );
697 assert!(
698 texts.iter().any(|t| t == "Select all"),
699 "expected Select all inside a subcomposition, got {texts:?}"
700 );
701 assert_eq!(
702 image_count(&scene),
703 2,
704 "a touch range selection should show two finger teardrop handles in \
705 the overlay across the subcomposition boundary"
706 );
707 }
708
709 #[test]
710 fn selection_handles_and_menu_survive_lazy_column_item() {
711 let _app_context = crate::render_state::app_context_test_scope();
718 let scene = render_range_menu_lazy_column(true);
719
720 let texts = text_values(&scene);
721 assert!(
722 texts.iter().any(|t| t == "Copy"),
723 "a touch selection inside a LazyColumn item should show the Copy menu \
724 item through the host, got {texts:?}"
725 );
726 assert!(
727 texts.iter().any(|t| t == "Select all"),
728 "expected Select all inside a LazyColumn item, got {texts:?}"
729 );
730 assert_eq!(
731 image_count(&scene),
732 2,
733 "a touch range selection should show two finger teardrop handles in \
734 the overlay across the LazyColumn item subcomposition boundary"
735 );
736 }
737
738 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
739 use crate::renderer::RenderOp;
740 use cranpose_ui_graphics::DrawPrimitive;
741 scene
742 .operations()
743 .iter()
744 .filter(|op| {
745 matches!(
746 op,
747 RenderOp::Primitive {
748 primitive: DrawPrimitive::Image { .. },
749 ..
750 }
751 )
752 })
753 .count()
754 }
755
756 #[test]
757 fn cursor_handle_shows_for_touch_only() {
758 let _app_context = crate::render_state::app_context_test_scope();
759 assert_eq!(
760 image_count(&render_collapsed_handles(true)),
761 1,
762 "a touch caret should show one finger cursor handle in the overlay"
763 );
764 assert_eq!(
765 image_count(&render_collapsed_handles(false)),
766 0,
767 "a mouse caret should keep a clean caret with no finger handle"
768 );
769 }
770
771 #[test]
772 fn basic_text_field_creates_node() {
773 let _app_context = crate::render_state::app_context_test_scope();
774 let mut composition = Composition::new(MemoryApplier::new());
775 let state = TextFieldState::new("Test content");
776
777 let result = composition.render(location_key(file!(), line!(), column!()), {
778 let state = state.clone();
779 move || {
780 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
781 }
782 });
783
784 assert!(result.is_ok());
785 assert!(composition.root().is_some());
786 }
787
788 #[test]
789 fn basic_text_field_state_updates() {
790 let _app_context = crate::render_state::app_context_test_scope();
791 with_test_runtime(|| {
792 let state = TextFieldState::new("Hello");
793 assert_eq!(state.text(), "Hello");
794
795 state.edit(|buffer| {
796 buffer.place_cursor_at_end();
797 buffer.insert("!");
798 });
799
800 assert_eq!(state.text(), "Hello!");
801 });
802 }
803}