1use std::{
7 cell::{Cell, RefCell},
8 rc::{Rc, Weak},
9};
10
11use cranpose_core::{MutableState, NodeId, SideEffect, mutableStateOf, remember};
12use cranpose_foundation::{
13 modifier_element,
14 text::{TextFieldLineLimits, TextFieldState, TextRange},
15};
16use cranpose_ui_graphics::{Color, Point, Rect};
17
18use crate::{
19 bring_into_view::local_bring_into_view_responder,
20 clipboard_session::{clipboard_can_paste, clipboard_paste_into_focus, clipboard_write_text},
21 composable,
22 layout::{
23 core::Alignment,
24 policies::{BoxMeasurePolicy, EmptyMeasurePolicy},
25 },
26 modifier::Modifier,
27 safe_area::local_ime_insets,
28 text::{AnnotatedString, TextStyle, measure_text},
29 text_field_decorator_node::TextFieldDecoratorElement,
30 text_field_focus::{dispatch_copy, dispatch_cut, dispatch_select_all},
31 text_field_modifier_node::{
32 TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics, TextFieldRefs,
33 },
34 text_selection::{
35 HANDLE_RADIUS, HandleGrabOffset, HandleKind, LineAffinity, selection_after_handle_drag,
36 },
37 widgets::{
38 CaretActionMenu, Layout, MenuAnchor, SelectionHandle, SelectionLoupe, TextSelectionMenu,
39 loupe_target_for_drag,
40 },
41};
42
43pub const SELECTION_HIGHLIGHT_ALPHA: f32 = 0.32;
48
49const TEXT_LONG_PRESS_MS: u64 = 500;
52const TEXT_LONG_PRESS_SLOP: f32 = 12.0;
55
56struct LongPressWatcher {
63 controller: TextFieldHandleController,
64 state: TextFieldState,
65 style: TextStyle,
66 start: Point,
67 start_nanos: Cell<Option<u64>>,
68 registration: RefCell<Option<cranpose_core::internal::FrameCallbackRegistration>>,
69 frame_clock: cranpose_core::internal::FrameClock,
70}
71
72impl LongPressWatcher {
73 fn arm(self: &Rc<Self>) {
74 let weak: Weak<LongPressWatcher> = Rc::downgrade(self);
75 let registration = self.frame_clock.with_frame_nanos(move |now| {
76 let Some(watcher) = weak.upgrade() else {
77 return;
78 };
79 watcher.tick(now);
80 });
81 *self.registration.borrow_mut() = Some(registration);
82 crate::request_render_invalidation();
83 }
84
85 fn tick(self: Rc<Self>, now: u64) {
86 self.registration.borrow_mut().take();
87 let Some(press) = self.controller.press() else {
88 return;
89 };
90 let moved = (press.position.x - self.start.x)
91 .abs()
92 .max((press.position.y - self.start.y).abs());
93 if (press.start.x - self.start.x).abs() > 0.5
94 || (press.start.y - self.start.y).abs() > 0.5
95 || moved > TEXT_LONG_PRESS_SLOP
96 {
97 return;
98 }
99 let start = match self.start_nanos.get() {
100 Some(value) => value,
101 None => {
102 self.start_nanos.set(Some(now));
103 now
104 }
105 };
106 if now.saturating_sub(start) < TEXT_LONG_PRESS_MS * 1_000_000 {
107 self.arm();
108 return;
109 }
110 self.controller.claim_gesture();
111 let Some(metrics) = self.controller.metrics() else {
112 return;
113 };
114 let text = self.state.text();
115 let offset = window_pos_to_offset(&text, &self.style, &metrics, self.start, 0.0);
116 let (word_start, word_end) = crate::word_boundaries::find_word_boundaries(&text, offset);
117 self.state.edit(|buffer| {
118 buffer.select(TextRange::new(word_start, word_end));
119 });
120 crate::request_render_invalidation();
121 }
122}
123
124fn handle_tip_window_pos(
130 text: &str,
131 style: &TextStyle,
132 metrics: &TextFieldHandleMetrics,
133 offset: usize,
134 affinity: LineAffinity,
135) -> Point {
136 let offset = offset.min(text.len());
137 let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
138 text,
139 style,
140 None,
141 metrics.wrap_width,
142 offset,
143 affinity,
144 );
145 let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
146 Point {
147 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
148 y: metrics.node_origin.y
149 + metrics.padding_top
150 + line_index as f32 * metrics.line_height
151 + metrics.glyph_box.0
152 + metrics.glyph_box.1,
153 }
154}
155
156fn window_pos_to_offset(
163 text: &str,
164 style: &TextStyle,
165 metrics: &TextFieldHandleMetrics,
166 window_pos: Point,
167 y_bias: f32,
168) -> usize {
169 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
170 + metrics.scroll_offset)
171 .max(0.0);
172 let local_y = (window_pos.y + y_bias
173 - 0.5 * metrics.line_height
174 - metrics.node_origin.y
175 - metrics.padding_top)
176 .max(0.0);
177 crate::text::offset_for_position_wrapped(
178 text,
179 style,
180 None,
181 metrics.wrap_width,
182 metrics.line_height,
183 local_x,
184 local_y,
185 )
186}
187#[composable]
205pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
206 BasicTextFieldWithOptions(
207 state,
208 modifier,
209 BasicTextFieldOptions {
210 text_style: style,
211 ..BasicTextFieldOptions::default()
212 },
213 )
214}
215
216#[derive(Debug, Clone, PartialEq)]
218pub struct BasicTextFieldOptions {
219 pub text_style: TextStyle,
221 pub cursor_color: Color,
223 pub line_limits: TextFieldLineLimits,
225}
226
227impl Default for BasicTextFieldOptions {
228 fn default() -> Self {
229 Self {
230 text_style: TextStyle::default(),
231 cursor_color: Color(0.0, 0.478, 1.0, 1.0),
232 line_limits: TextFieldLineLimits::default(),
233 }
234 }
235}
236
237#[derive(Clone)]
242pub struct BasicTextFieldDecorationScope {
243 inner: Rc<dyn Fn() -> NodeId>,
244}
245
246impl BasicTextFieldDecorationScope {
247 pub fn inner_text_field(&self) -> NodeId {
248 (self.inner)()
249 }
250}
251
252impl PartialEq for BasicTextFieldDecorationScope {
253 fn eq(&self, other: &Self) -> bool {
254 Rc::ptr_eq(&self.inner, &other.inner)
255 }
256}
257
258#[composable(no_skip)]
267pub fn BasicTextFieldDecorated<D>(
268 state: TextFieldState,
269 modifier: Modifier,
270 options: BasicTextFieldOptions,
271 decoration_box: D,
272) -> NodeId
273where
274 D: Fn(BasicTextFieldDecorationScope) + 'static,
275{
276 let refs = remember(TextFieldRefs::new).with(TextFieldRefs::clone);
277 let decorator = TextFieldDecoratorElement::new(
278 state,
279 refs.clone(),
280 options.text_style.clone(),
281 options.line_limits,
282 crate::modal::local_modal_depth().current(),
283 );
284 let scope = BasicTextFieldDecorationScope {
285 inner: Rc::new(move || {
286 TextFieldNode(
287 state,
288 Modifier::empty(),
289 options.clone(),
290 Some(refs.clone()),
291 )
292 }),
293 };
294 Layout(
295 modifier.then(Modifier::from_parts(vec![modifier_element(decorator)])),
296 BoxMeasurePolicy::new(Alignment::TOP_START, true),
297 move || decoration_box(scope.clone()),
298 )
299}
300
301#[composable]
305pub fn BasicTextFieldWithOptions(
306 state: TextFieldState,
307 modifier: Modifier,
308 options: BasicTextFieldOptions,
309) -> NodeId {
310 TextFieldNode(state, modifier, options, None)
311}
312
313#[composable]
317fn TextFieldNode(
318 state: TextFieldState,
319 modifier: Modifier,
320 options: BasicTextFieldOptions,
321 decorator: Option<TextFieldRefs>,
322) -> NodeId {
323 let _text = state.text();
324 let _selection = state.selection();
325
326 let controller =
327 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
328
329 let modal_depth = crate::modal::local_modal_depth().current();
330 let mut text_field_element = TextFieldElement::new(state, options.text_style.clone())
331 .with_cursor_color(options.cursor_color)
332 .with_line_limits(options.line_limits)
333 .with_handle_controller(controller.clone())
334 .with_modal_depth(modal_depth);
335 if let Some(refs) = decorator {
336 text_field_element = text_field_element.decorated_by(refs);
337 }
338
339 let text_field_modifier = modifier_element(text_field_element);
340 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
341 let combined_modifier = modifier.then(final_modifier);
342
343 let node = Layout(combined_modifier, EmptyMeasurePolicy, || {});
344
345 BringCaretIntoView(state, options.text_style.clone(), controller.clone());
346
347 SelectionHandles(state, options.text_style, controller, options.cursor_color);
348
349 node
350}
351
352#[cfg(test)]
353#[path = "tests/basic_text_field_options_tests.rs"]
354mod options_tests;
355
356fn caret_window_rect(
360 text: &str,
361 style: &TextStyle,
362 metrics: &TextFieldHandleMetrics,
363 offset: usize,
364) -> Rect {
365 let tip = handle_tip_window_pos(text, style, metrics, offset, LineAffinity::Upstream);
366 Rect {
367 x: tip.x,
368 y: tip.y - metrics.glyph_box.1,
369 width: 2.0,
370 height: metrics.glyph_box.1,
371 }
372}
373
374#[composable]
384fn BringCaretIntoView(
385 state: TextFieldState,
386 style: TextStyle,
387 controller: TextFieldHandleController,
388) {
389 let Some(metrics) = controller.metrics() else {
390 return;
391 };
392 let ime_bottom = local_ime_insets().current().bottom;
393 let responder = local_bring_into_view_responder().current();
394
395 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
396 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
397
398 if !metrics.focused {
399 previous.set(None);
400 return;
401 }
402 let Some(responder) = responder else {
403 return;
404 };
405
406 let text = state.text();
407 let selection = state.selection();
408
409 let key = (
410 selection.start,
411 selection.end,
412 (ime_bottom * 4.0).round() as i64,
413 );
414 SideEffect(move || {
415 if previous.get() == Some(key) {
416 return;
417 }
418 previous.set(Some(key));
419 let Some(metrics) = controller.metrics_now() else {
420 return;
421 };
422 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
423 responder.bring_into_view(caret, ime_bottom);
424 });
425}
426
427#[composable]
432fn SelectionHandles(
433 state: TextFieldState,
434 style: TextStyle,
435 controller: TextFieldHandleController,
436 accent: Color,
437) {
438 let selection = state.selection();
439 let current_range = (selection.min(), selection.max());
440 let active_press = controller.press();
441
442 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
443 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
444 let caret_menu_offset: Rc<Cell<usize>> =
445 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
446 let previous_range: Rc<Cell<(usize, usize)>> =
447 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
448 {
449 let previous_range = Rc::clone(&previous_range);
450 SideEffect(move || {
451 if previous_range.get() != current_range {
452 previous_range.set(current_range);
453 menu_open.set(true);
454 }
455 });
456 }
457 {
458 let caret_menu_offset = Rc::clone(&caret_menu_offset);
459 let caret_start = selection.start;
460 SideEffect(move || {
461 if caret_menu_open.value()
462 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
463 {
464 caret_menu_open.set(false);
465 }
466 });
467 }
468
469 let Some(metrics) = controller.metrics() else {
470 return;
471 };
472 if !metrics.focused || !metrics.direct_manipulation {
473 return;
474 }
475 let Some(metrics) = controller.live_metrics() else {
478 return;
479 };
480
481 let text = state.text();
482
483 let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
484 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
485 let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
486 remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
487 match active_press {
488 Some(press) => {
489 let key = (press.start.x.to_bits(), press.start.y.to_bits());
490 if press_watcher.get() != Some(key) {
491 press_watcher.set(Some(key));
492 let watcher = Rc::new(LongPressWatcher {
493 controller: controller.clone(),
494 state,
495 style: style.clone(),
496 start: press.start,
497 start_nanos: Cell::new(None),
498 registration: RefCell::new(None),
499 frame_clock: cranpose_core::with_current_composer(|composer| {
500 composer.runtime_handle()
501 })
502 .frame_clock(),
503 });
504 watcher.arm();
505 *press_watcher_ref.borrow_mut() = Some(watcher);
506 }
507 }
508 None => {
509 press_watcher.set(None);
510 press_watcher_ref.borrow_mut().take();
511 }
512 }
513
514 let drag_pos: MutableState<Option<Point>> =
515 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
516 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
517 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
518 let last_dragged: Rc<Cell<Option<HandleKind>>> =
519 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
520 let menu_anchor_range: Rc<Cell<(usize, usize)>> =
521 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
522 {
523 let last_dragged = Rc::clone(&last_dragged);
524 let menu_anchor_range = Rc::clone(&menu_anchor_range);
525 SideEffect(move || {
526 if menu_anchor_range.get() != current_range {
527 menu_anchor_range.set(current_range);
528 if drag_pos.value().is_none() {
529 last_dragged.set(None);
530 }
531 }
532 });
533 }
534 let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
535 let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
536 let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
537
538 if selection.collapsed() {
539 let tip = handle_tip_window_pos(
540 &text,
541 &style,
542 &metrics,
543 selection.start,
544 LineAffinity::Upstream,
545 );
546 let on_drag = drag_caret_closure(state, style.clone(), controller, Rc::clone(&drag_bias));
547 let open_caret_menu = {
548 let caret_menu_offset = Rc::clone(&caret_menu_offset);
549 move || {
550 caret_menu_offset.set(state.selection().start);
551 caret_menu_open.set(true);
552 }
553 };
554 let on_tap = open_caret_menu.clone();
555 let on_long_press = open_caret_menu;
556 let grab_bias = Rc::clone(&drag_bias);
557 let end_bias = Rc::clone(&drag_bias);
558 cursor_tip_y.set(tip.y);
559 let tip_y = Rc::clone(&cursor_tip_y);
560 SelectionHandle(
561 HandleKind::Cursor,
562 tip,
563 metrics.glyph_box.1,
564 HANDLE_RADIUS,
565 accent,
566 move |pos| {
567 track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
568 drag_pos.set(Some(pos));
569 on_drag(pos);
570 },
571 move || {
572 drag_pos.set(None);
573 end_bias.set(None);
574 crate::cursor_animation::reset_cursor_blink();
575 },
576 on_long_press,
577 on_tap,
578 );
579
580 if caret_menu_open.value() {
581 let can_paste = clipboard_can_paste();
582 let can_undo = state.can_undo();
583 let can_redo = state.can_redo();
584 let undo_state = state;
585 let redo_state = state;
586 CaretActionMenu(
587 MenuAnchor {
588 center_x: tip.x,
589 line_top: tip.y - metrics.glyph_box.1,
590 line_bottom: tip.y,
591 },
592 drag_pos.value().is_none(),
593 can_paste,
594 can_undo,
595 can_redo,
596 move || {
597 clipboard_paste_into_focus();
598 caret_menu_open.set(false);
599 },
600 move || {
601 dispatch_select_all();
602 caret_menu_open.set(false);
603 },
604 move || {
605 undo_state.undo();
606 crate::request_render_invalidation();
607 caret_menu_open.set(false);
608 },
609 move || {
610 redo_state.redo();
611 crate::request_render_invalidation();
612 caret_menu_open.set(false);
613 },
614 );
615 }
616 } else {
617 let start = selection.min();
618 let end = selection.max();
619 let start_tip =
620 handle_tip_window_pos(&text, &style, &metrics, start, LineAffinity::Downstream);
621 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end, LineAffinity::Upstream);
622
623 let last_dragged_start = Rc::clone(&last_dragged);
624 let last_dragged_end = Rc::clone(&last_dragged);
625 let on_drag_start = drag_edge_closure(
626 HandleKind::SelectionStart,
627 state,
628 style.clone(),
629 controller.clone(),
630 Rc::clone(&drag_bias),
631 );
632 let grab_bias = Rc::clone(&drag_bias);
633 let end_bias = Rc::clone(&drag_bias);
634 start_tip_y.set(start_tip.y);
635 let start_tip_live = Rc::clone(&start_tip_y);
636 SelectionHandle(
637 HandleKind::SelectionStart,
638 start_tip,
639 metrics.glyph_box.1,
640 HANDLE_RADIUS,
641 accent,
642 move |pos| {
643 track_handle_grab(
644 &grab_bias,
645 HandleKind::SelectionStart,
646 start_tip_live.get(),
647 pos.y,
648 );
649 last_dragged_start.set(Some(HandleKind::SelectionStart));
650 drag_pos.set(Some(pos));
651 on_drag_start(pos);
652 },
653 move || {
654 drag_pos.set(None);
655 end_bias.set(None);
656 },
657 move || menu_open.set(true),
658 move || menu_open.set(true),
659 );
660
661 let on_drag_end = drag_edge_closure(
662 HandleKind::SelectionEnd,
663 state,
664 style.clone(),
665 controller.clone(),
666 Rc::clone(&drag_bias),
667 );
668 let grab_bias = Rc::clone(&drag_bias);
669 let end_bias = Rc::clone(&drag_bias);
670 end_tip_y.set(end_tip.y);
671 let end_tip_live = Rc::clone(&end_tip_y);
672 SelectionHandle(
673 HandleKind::SelectionEnd,
674 end_tip,
675 metrics.glyph_box.1,
676 HANDLE_RADIUS,
677 accent,
678 move |pos| {
679 track_handle_grab(
680 &grab_bias,
681 HandleKind::SelectionEnd,
682 end_tip_live.get(),
683 pos.y,
684 );
685 last_dragged_end.set(Some(HandleKind::SelectionEnd));
686 drag_pos.set(Some(pos));
687 on_drag_end(pos);
688 },
689 move || {
690 drag_pos.set(None);
691 end_bias.set(None);
692 },
693 move || menu_open.set(true),
694 move || menu_open.set(true),
695 );
696
697 if menu_open.value() {
698 let can_paste = clipboard_can_paste();
699 let slide_point = if controller.gesture_claimed() {
700 active_press.map(|press| press.position)
701 } else {
702 None
703 };
704 let (center_x, line_bottom) = match last_dragged.get() {
705 Some(HandleKind::SelectionStart) => (start_tip.x, start_tip.y),
706 Some(HandleKind::SelectionEnd | HandleKind::Cursor) => (end_tip.x, end_tip.y),
707 None => ((start_tip.x + end_tip.x) * 0.5, start_tip.y),
708 };
709 TextSelectionMenu(
710 MenuAnchor {
711 center_x,
712 line_top: line_bottom - metrics.glyph_box.1,
713 line_bottom,
714 },
715 drag_pos.value().is_none(),
716 slide_point,
717 can_paste,
718 move || {
719 if let Some(text) = dispatch_copy() {
720 clipboard_write_text(&text);
721 }
722 menu_open.set(false);
723 },
724 move || {
725 if let Some(text) = dispatch_cut() {
726 clipboard_write_text(&text);
727 }
728 menu_open.set(false);
729 },
730 move || {
731 clipboard_paste_into_focus();
732 menu_open.set(false);
733 },
734 move || {
735 dispatch_select_all();
736 menu_open.set(false);
737 },
738 );
739 }
740 }
741
742 let loupe_target = drag_pos.value().and_then(|finger| {
743 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
744 let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
745 let line_bottom =
746 handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
747 loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
748 });
749 SelectionLoupe(loupe_target);
750}
751
752fn track_handle_grab(
753 drag_bias: &Cell<Option<HandleGrabOffset>>,
754 kind: HandleKind,
755 handle_tip_y: f32,
756 finger_y: f32,
757) -> f32 {
758 let drifts = kind != HandleKind::SelectionStart;
759 let mut grab = drag_bias
760 .get()
761 .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
762 let bias = grab.track(finger_y);
763 drag_bias.set(Some(grab));
764 bias
765}
766
767fn drag_caret_closure(
771 state: TextFieldState,
772 style: TextStyle,
773 controller: TextFieldHandleController,
774 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
775) -> Rc<dyn Fn(Point)> {
776 Rc::new(move |window_pos: Point| {
777 let Some(metrics) = controller.metrics() else {
778 return;
779 };
780 let text = state.text();
781 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
782 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
783 state.set_selection(TextRange::new(offset, offset));
784 crate::cursor_animation::suspend_cursor_blink();
785 crate::request_render_invalidation();
786 })
787}
788
789fn drag_edge_closure(
793 dragged: HandleKind,
794 state: TextFieldState,
795 style: TextStyle,
796 controller: TextFieldHandleController,
797 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
798) -> Rc<dyn Fn(Point)> {
799 Rc::new(move |window_pos: Point| {
800 let Some(metrics) = controller.metrics() else {
801 return;
802 };
803 let text = state.text();
804 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
805 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
806 let selection = state.selection();
807 let fixed_edge = match dragged {
808 HandleKind::SelectionStart => selection.max(),
809 _ => selection.min(),
810 };
811 let (min, max) =
812 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
813 state.set_selection(TextRange::new(min, max));
814 crate::request_render_invalidation();
815 })
816}
817
818#[cfg(test)]
819#[path = "tests/basic_text_field_tests.rs"]
820mod tests;