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