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 } else {
215 let start = selection.min();
217 let end = selection.max();
218 let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
219 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);
220
221 let on_drag_start = drag_edge_closure(
222 HandleKind::SelectionStart,
223 state.clone(),
224 style.clone(),
225 controller.clone(),
226 );
227 SelectionHandle(
228 HandleKind::SelectionStart,
229 start_tip,
230 HANDLE_RADIUS,
231 HANDLE_COLOR,
232 move |pos| on_drag_start(pos),
233 || {},
234 );
235
236 let on_drag_end = drag_edge_closure(
237 HandleKind::SelectionEnd,
238 state.clone(),
239 style.clone(),
240 controller.clone(),
241 );
242 SelectionHandle(
243 HandleKind::SelectionEnd,
244 end_tip,
245 HANDLE_RADIUS,
246 HANDLE_COLOR,
247 move |pos| on_drag_end(pos),
248 || {},
249 );
250
251 if menu_open.value() {
254 let menu_anchor = Point {
256 x: (start_tip.x + end_tip.x) * 0.5,
257 y: start_tip.y - metrics.line_height,
258 };
259 let can_paste = clipboard_read_text().is_some();
260 TextSelectionMenu(
261 menu_anchor,
262 can_paste,
263 move || {
264 if let Some(text) = dispatch_copy() {
265 clipboard_write_text(&text);
266 }
267 menu_open.set(false);
268 },
269 move || {
270 if let Some(text) = dispatch_cut() {
271 clipboard_write_text(&text);
272 }
273 menu_open.set(false);
274 },
275 move || {
276 if let Some(text) = clipboard_read_text() {
277 dispatch_paste(&text);
278 }
279 menu_open.set(false);
280 },
281 move || {
282 dispatch_select_all();
283 menu_open.set(false);
284 },
285 );
286 }
287 }
288}
289
290fn drag_caret_closure(
293 state: TextFieldState,
294 style: TextStyle,
295 controller: TextFieldHandleController,
296) -> Rc<dyn Fn(Point)> {
297 Rc::new(move |window_pos: Point| {
298 let Some(metrics) = controller.metrics() else {
299 return;
300 };
301 let text = state.text();
302 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
303 state.set_selection(TextRange::new(offset, offset));
304 crate::request_render_invalidation();
305 })
306}
307
308fn drag_edge_closure(
312 dragged: HandleKind,
313 state: TextFieldState,
314 style: TextStyle,
315 controller: TextFieldHandleController,
316) -> Rc<dyn Fn(Point)> {
317 Rc::new(move |window_pos: Point| {
318 let Some(metrics) = controller.metrics() else {
319 return;
320 };
321 let text = state.text();
322 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
323 let selection = state.selection();
324 let fixed_edge = match dragged {
325 HandleKind::SelectionStart => selection.max(),
326 _ => selection.min(),
327 };
328 let (min, max) =
329 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
330 state.set_selection(TextRange::new(min, max));
331 crate::request_render_invalidation();
332 })
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
339 use std::sync::Arc;
340
341 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
343 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
344 f()
345 }
346
347 fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
351 use crate::layout::LayoutEngine;
352 use crate::renderer::HeadlessRenderer;
353 use crate::widgets::PopupHost;
354 use cranpose_ui_graphics::Size;
355
356 let mut composition = Composition::new(MemoryApplier::new());
357 let key = location_key(file!(), line!(), column!());
358 let state = TextFieldState::new("hello world");
359
360 let mut content = {
361 let state = state.clone();
362 move || {
363 let state = state.clone();
364 PopupHost(move || {
365 let controller = TextFieldHandleController::new();
366 controller.publish(TextFieldHandleMetrics {
367 focused: true,
368 touch,
369 node_origin: Point { x: 0.0, y: 10.0 },
370 padding_left: 0.0,
371 padding_top: 0.0,
372 scroll_offset: 0.0,
373 line_height: 18.0,
374 });
375 SelectionHandles(state.clone(), TextStyle::default(), controller);
376 });
377 }
378 };
379
380 composition.render(key, &mut content).expect("render");
381 for _ in 0..16 {
382 if !composition.should_render() {
383 break;
384 }
385 composition.reconcile(key, &mut content).expect("reconcile");
386 }
387 let root = composition.root().expect("root");
388 let handle = composition.runtime_handle();
389 let mut applier = composition.applier_mut();
390 applier.set_runtime_handle(handle);
391 let layout = applier
392 .compute_layout(
393 root,
394 Size {
395 width: 400.0,
396 height: 400.0,
397 },
398 )
399 .expect("layout");
400 applier.clear_runtime_handle();
401 drop(applier);
402 HeadlessRenderer::new().render(&layout)
403 }
404
405 fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
408 use crate::layout::LayoutEngine;
409 use crate::renderer::HeadlessRenderer;
410 use crate::widgets::PopupHost;
411 use cranpose_ui_graphics::Size;
412
413 let mut composition = Composition::new(MemoryApplier::new());
414 let key = location_key(file!(), line!(), column!());
415 let state = TextFieldState::new("hello world");
416
417 let mut content = {
418 let state = state.clone();
419 move || {
420 let state = state.clone();
421 PopupHost(move || {
422 let controller = TextFieldHandleController::new();
423 if state.selection() != TextRange::new(0, 5) {
424 state.set_selection(TextRange::new(0, 5));
425 }
426 controller.publish(TextFieldHandleMetrics {
427 focused: true,
428 touch,
429 node_origin: Point { x: 0.0, y: 40.0 },
430 padding_left: 0.0,
431 padding_top: 0.0,
432 scroll_offset: 0.0,
433 line_height: 18.0,
434 });
435 SelectionHandles(state.clone(), TextStyle::default(), controller);
436 });
437 }
438 };
439
440 composition.render(key, &mut content).expect("render");
441 for _ in 0..16 {
442 if !composition.should_render() {
443 break;
444 }
445 composition.reconcile(key, &mut content).expect("reconcile");
446 }
447 let root = composition.root().expect("root");
448 let handle = composition.runtime_handle();
449 let mut applier = composition.applier_mut();
450 applier.set_runtime_handle(handle);
451 let layout = applier
452 .compute_layout(
453 root,
454 Size {
455 width: 400.0,
456 height: 400.0,
457 },
458 )
459 .expect("layout");
460 applier.clear_runtime_handle();
461 drop(applier);
462 HeadlessRenderer::new().render(&layout)
463 }
464
465 fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
471 use crate::layout::LayoutEngine;
472 use crate::renderer::HeadlessRenderer;
473 use crate::widgets::{BoxWithConstraints, 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 state = state.clone();
486 BoxWithConstraints(
487 Modifier::empty().size(Size {
488 width: 300.0,
489 height: 300.0,
490 }),
491 move |_scope| {
492 let controller = TextFieldHandleController::new();
493 if state.selection() != TextRange::new(0, 5) {
494 state.set_selection(TextRange::new(0, 5));
495 }
496 controller.publish(TextFieldHandleMetrics {
497 focused: true,
498 touch,
499 node_origin: Point { x: 0.0, y: 40.0 },
500 padding_left: 0.0,
501 padding_top: 0.0,
502 scroll_offset: 0.0,
503 line_height: 18.0,
504 });
505 SelectionHandles(state.clone(), TextStyle::default(), controller);
506 },
507 );
508 });
509 }
510 };
511
512 composition.render(key, &mut content).expect("render");
513 let root = composition.root().expect("root");
514 let handle = composition.runtime_handle();
515 let mut scene = None;
516 for _ in 0..8 {
520 for _ in 0..16 {
521 if !composition.should_render() {
522 break;
523 }
524 composition.reconcile(key, &mut content).expect("reconcile");
525 }
526 let mut applier = composition.applier_mut();
527 applier.set_runtime_handle(handle.clone());
528 let layout = applier
529 .compute_layout(
530 root,
531 Size {
532 width: 400.0,
533 height: 400.0,
534 },
535 )
536 .expect("layout");
537 applier.clear_runtime_handle();
538 drop(applier);
539 scene = Some(HeadlessRenderer::new().render(&layout));
540 }
541 scene.expect("scene")
542 }
543
544 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
545 use crate::renderer::RenderOp;
546 scene
547 .operations()
548 .iter()
549 .filter_map(|op| match op {
550 RenderOp::Text { value, .. } => Some(value.clone()),
551 _ => None,
552 })
553 .collect()
554 }
555
556 #[test]
557 fn context_menu_shows_for_touch_selection_only() {
558 let _app_context = crate::render_state::app_context_test_scope();
559
560 let touch = text_values(&render_range_menu(true));
561 assert!(
562 touch.iter().any(|t| t == "Copy"),
563 "touch selection should show the Copy menu item, got {touch:?}"
564 );
565 assert!(
566 touch.iter().any(|t| t == "Cut"),
567 "expected Cut, got {touch:?}"
568 );
569 assert!(
570 touch.iter().any(|t| t == "Select all"),
571 "expected Select all, got {touch:?}"
572 );
573
574 let mouse = text_values(&render_range_menu(false));
575 assert!(
576 !mouse.iter().any(|t| t == "Copy"),
577 "mouse selection must not show the finger contextual menu, got {mouse:?}"
578 );
579 }
580
581 #[test]
582 fn selection_handles_and_menu_survive_subcomposition() {
583 let _app_context = crate::render_state::app_context_test_scope();
589 let scene = render_range_menu_subcomposed(true);
590
591 let texts = text_values(&scene);
592 assert!(
593 texts.iter().any(|t| t == "Copy"),
594 "a touch selection inside a subcomposition should show the Copy menu \
595 item through the host, got {texts:?}"
596 );
597 assert!(
598 texts.iter().any(|t| t == "Select all"),
599 "expected Select all inside a subcomposition, got {texts:?}"
600 );
601 assert_eq!(
602 image_count(&scene),
603 2,
604 "a touch range selection should show two finger teardrop handles in \
605 the overlay across the subcomposition boundary"
606 );
607 }
608
609 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
610 use crate::renderer::RenderOp;
611 use cranpose_ui_graphics::DrawPrimitive;
612 scene
613 .operations()
614 .iter()
615 .filter(|op| {
616 matches!(
617 op,
618 RenderOp::Primitive {
619 primitive: DrawPrimitive::Image { .. },
620 ..
621 }
622 )
623 })
624 .count()
625 }
626
627 #[test]
628 fn cursor_handle_shows_for_touch_only() {
629 let _app_context = crate::render_state::app_context_test_scope();
630 assert_eq!(
631 image_count(&render_collapsed_handles(true)),
632 1,
633 "a touch caret should show one finger cursor handle in the overlay"
634 );
635 assert_eq!(
636 image_count(&render_collapsed_handles(false)),
637 0,
638 "a mouse caret should keep a clean caret with no finger handle"
639 );
640 }
641
642 #[test]
643 fn basic_text_field_creates_node() {
644 let _app_context = crate::render_state::app_context_test_scope();
645 let mut composition = Composition::new(MemoryApplier::new());
646 let state = TextFieldState::new("Test content");
647
648 let result = composition.render(location_key(file!(), line!(), column!()), {
649 let state = state.clone();
650 move || {
651 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
652 }
653 });
654
655 assert!(result.is_ok());
656 assert!(composition.root().is_some());
657 }
658
659 #[test]
660 fn basic_text_field_state_updates() {
661 let _app_context = crate::render_state::app_context_test_scope();
662 with_test_runtime(|| {
663 let state = TextFieldState::new("Hello");
664 assert_eq!(state.text(), "Hello");
665
666 state.edit(|buffer| {
667 buffer.place_cursor_at_end();
668 buffer.insert("!");
669 });
670
671 assert_eq!(state.text(), "Hello!");
672 });
673 }
674}