1use cranpose_core::{mutableStateOf, MutableState};
20use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
21use cranpose_foundation::{
22 Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
23 LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
24 NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode,
25 SemanticsConfiguration, SemanticsNode, Size,
26};
27use cranpose_ui_graphics::{Brush, Color, Point};
28use std::cell::{Cell, RefCell};
29use std::hash::{Hash, Hasher};
30use std::rc::Rc;
31
32#[derive(Clone, Copy, PartialEq, Debug)]
37pub struct TextFieldHandleMetrics {
38 pub focused: bool,
39 pub direct_manipulation: bool,
42 pub node_origin: Point,
44 pub padding_left: f32,
45 pub padding_top: f32,
46 pub scroll_offset: f32,
47 pub line_height: f32,
48 pub glyph_box: (f32, f32),
52 pub wrap_width: Option<f32>,
55 pub press: Option<PointerPressTrack>,
58}
59
60#[derive(Clone)]
65pub struct TextFieldHandleController {
66 inner: Rc<TextFieldHandleControllerInner>,
67}
68
69impl PartialEq for TextFieldHandleController {
70 fn eq(&self, other: &Self) -> bool {
71 Rc::ptr_eq(&self.inner, &other.inner)
72 }
73}
74
75struct TextFieldHandleControllerInner {
76 metrics: Cell<Option<TextFieldHandleMetrics>>,
77 revision: MutableState<u64>,
78 gesture_claim: RefCell<Option<Rc<Cell<bool>>>>,
82}
83
84impl TextFieldHandleController {
85 pub fn new() -> Self {
88 Self {
89 inner: Rc::new(TextFieldHandleControllerInner {
90 metrics: Cell::new(None),
91 revision: mutableStateOf(0u64),
92 gesture_claim: RefCell::new(None),
93 }),
94 }
95 }
96
97 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
100 if self.inner.metrics.get() != Some(metrics) {
101 self.inner.metrics.set(Some(metrics));
102 self.inner
103 .revision
104 .update(|value| *value = value.wrapping_add(1));
105 }
106 }
107
108 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
111 let _ = self.inner.revision.value();
112 self.inner.metrics.get()
113 }
114
115 pub(crate) fn adopt_gesture_claim(&self, claim: &Rc<Cell<bool>>) {
117 let mut slot = self.inner.gesture_claim.borrow_mut();
118 let adopted = slot.as_ref().is_some_and(|held| Rc::ptr_eq(held, claim));
119 if !adopted {
120 *slot = Some(Rc::clone(claim));
121 }
122 }
123
124 pub fn claim_gesture(&self) {
127 if let Some(claim) = self.inner.gesture_claim.borrow().as_ref() {
128 claim.set(true);
129 }
130 }
131
132 pub fn gesture_claimed(&self) -> bool {
134 self.inner
135 .gesture_claim
136 .borrow()
137 .as_ref()
138 .is_some_and(|claim| claim.get())
139 }
140}
141
142impl Default for TextFieldHandleController {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
150
151const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
153
154const DEFAULT_LINE_HEIGHT: f32 = 20.0;
156
157const CURSOR_WIDTH: f32 = 2.0;
159
160pub(crate) fn compute_horizontal_scroll_offset(
171 current_offset: f32,
172 cursor_x: f32,
173 text_width: f32,
174 viewport_width: f32,
175) -> f32 {
176 if viewport_width <= 0.0 {
177 return 0.0;
178 }
179 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
180 let mut offset = current_offset.clamp(0.0, max_offset);
181 let visible_end = offset + viewport_width - CURSOR_WIDTH;
182 if cursor_x > visible_end {
183 offset = cursor_x - viewport_width + CURSOR_WIDTH;
185 } else if cursor_x < offset {
186 offset = cursor_x;
188 }
189 offset.clamp(0.0, max_offset)
190}
191
192pub(crate) fn intersect_rect(
197 rect: cranpose_ui_graphics::Rect,
198 bounds: cranpose_ui_graphics::Rect,
199) -> Option<cranpose_ui_graphics::Rect> {
200 let x0 = rect.x.max(bounds.x);
201 let y0 = rect.y.max(bounds.y);
202 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
203 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
204 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
205 x: x0,
206 y: y0,
207 width: x1 - x0,
208 height: y1 - y0,
209 })
210}
211
212pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
215
216pub(crate) fn caret_visual_line_for_offset(
226 text: &str,
227 style: &TextStyle,
228 node_id: Option<cranpose_core::NodeId>,
229 wrap_width: Option<f32>,
230 offset: usize,
231 affinity: crate::text_selection::LineAffinity,
232) -> (usize, usize) {
233 let offset = offset.min(text.len());
234 match wrap_width {
235 Some(width) if width.is_finite() && width > 0.0 => {
236 let annotated = crate::text::AnnotatedString::from(text);
237 let ranges = crate::text::wrapped_line_ranges(
238 node_id,
239 &annotated,
240 style,
241 crate::text::TextLayoutOptions::default(),
242 Some(width),
243 );
244 crate::text_selection::caret_visual_line(&ranges, offset, affinity)
245 }
246 _ => {
247 let before = &text[..offset];
250 let line_index = before.matches('\n').count();
251 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
252 (line_index, line_start)
253 }
254 }
255}
256
257#[allow(clippy::too_many_arguments)]
268pub(crate) fn range_visual_line_rects(
269 text: &str,
270 style: &TextStyle,
271 node_id: Option<cranpose_core::NodeId>,
272 wrap_width: Option<f32>,
273 padding_left: f32,
274 padding_top: f32,
275 pan: f32,
276 line_height: f32,
277 start: usize,
278 end: usize,
279) -> Vec<cranpose_ui_graphics::Rect> {
280 if start >= end {
281 return Vec::new();
282 }
283 let annotated = crate::text::AnnotatedString::from(text);
284 let line_ranges = crate::text::wrapped_line_ranges(
285 node_id,
286 &annotated,
287 style,
288 crate::text::TextLayoutOptions::default(),
289 wrap_width,
290 );
291 let mut rects = Vec::new();
292 for (line_idx, line_range) in line_ranges.iter().enumerate() {
293 let line_start = line_range.start;
294 let line_end = line_range.end;
295 if end <= line_start || start >= line_end {
296 continue;
297 }
298 let seg_start = start.max(line_start);
299 let seg_end = end.min(line_end);
300 let x0 = crate::text::measure_text(
301 &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
302 style,
303 )
304 .width
305 + padding_left
306 - pan;
307 let x1 = crate::text::measure_text(
308 &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
309 style,
310 )
311 .width
312 + padding_left
313 - pan;
314 let width = x1 - x0;
315 if width > 0.0 {
316 rects.push(cranpose_ui_graphics::Rect {
317 x: x0,
318 y: padding_top + line_idx as f32 * line_height,
319 width,
320 height: line_height,
321 });
322 }
323 }
324 rects
325}
326
327#[derive(Clone)]
333pub(crate) struct TextFieldRefs {
334 pub is_focused: Rc<RefCell<bool>>,
336 pub content_offset: Rc<Cell<f32>>,
338 pub content_y_offset: Rc<Cell<f32>>,
340 pub drag_anchor: Rc<Cell<Option<usize>>>,
342 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
344 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
346 pub click_count: Rc<Cell<u8>>,
348 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
350 pub scroll_offset: Rc<Cell<f32>>,
353 pub direct_manipulation: Rc<Cell<bool>>,
357 pub node_origin: Rc<Cell<Point>>,
361 pub line_height: Rc<Cell<f32>>,
365 pub wrap_width: Rc<Cell<Option<f32>>>,
369 pub press_track: Rc<Cell<Option<PointerPressTrack>>>,
372 pub gesture_claimed: Rc<Cell<bool>>,
376}
377
378#[derive(Clone, Copy, Debug, PartialEq)]
381pub struct PointerPressTrack {
382 pub start: Point,
384 pub position: Point,
386}
387
388impl TextFieldRefs {
389 pub fn new() -> Self {
391 Self {
392 is_focused: Rc::new(RefCell::new(false)),
393 content_offset: Rc::new(Cell::new(0.0_f32)),
394 content_y_offset: Rc::new(Cell::new(0.0_f32)),
395 drag_anchor: Rc::new(Cell::new(None::<usize>)),
396 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
397 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
398 click_count: Rc::new(Cell::new(0_u8)),
399 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
400 scroll_offset: Rc::new(Cell::new(0.0_f32)),
401 direct_manipulation: Rc::new(Cell::new(false)),
402 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
403 line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
404 wrap_width: Rc::new(Cell::new(None::<f32>)),
405 press_track: Rc::new(Cell::new(None::<PointerPressTrack>)),
406 gesture_claimed: Rc::new(Cell::new(false)),
407 }
408 }
409}
410
411use crate::text::TextStyle; pub struct TextFieldModifierNode {
420 state: TextFieldState,
422 refs: TextFieldRefs,
424 style: TextStyle, cursor_brush: Brush,
428 selection_brush: Brush,
430 line_limits: TextFieldLineLimits,
432 cached_text: String,
434 cached_selection: TextRange,
436 node_state: NodeState,
438 measured_size: Rc<Cell<Size>>,
440 measured_line_height: Rc<Cell<f32>>,
442 measured_wrap_width: Rc<Cell<Option<f32>>>,
448 cached_handler: Rc<dyn Fn(PointerEvent)>,
450 cached_pan_resolver: TextPanResolver,
452 handle_controller: Option<TextFieldHandleController>,
456}
457
458impl std::fmt::Debug for TextFieldModifierNode {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 f.debug_struct("TextFieldModifierNode")
461 .field("text", &self.state.text())
462 .field("style", &self.style)
463 .field("is_focused", &*self.refs.is_focused.borrow())
464 .finish()
465 }
466}
467
468use crate::text_field_handler::TextFieldHandler;
470
471impl TextFieldModifierNode {
472 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
474 let value = state.value();
475 let refs = TextFieldRefs::new();
476 let refs_line_height = refs.line_height.clone();
477 let refs_wrap_width = refs.wrap_width.clone();
478 let line_limits = TextFieldLineLimits::default();
479 let cached_handler =
480 Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
481 let cached_pan_resolver =
482 Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
483
484 Self {
485 state,
486 refs,
487 style,
488 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
489 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
490 line_limits,
491 cached_text: value.text,
492 cached_selection: value.selection,
493 node_state: NodeState::new(),
494 measured_size: Rc::new(Cell::new(Size {
495 width: 0.0,
496 height: 0.0,
497 })),
498 measured_line_height: refs_line_height,
502 measured_wrap_width: refs_wrap_width,
503 cached_handler,
504 cached_pan_resolver,
505 handle_controller: None,
506 }
507 }
508
509 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
511 self.line_limits = line_limits;
512 self.cached_pan_resolver = Self::create_pan_resolver(
513 self.state.clone(),
514 self.refs.clone(),
515 line_limits,
516 self.style.clone(),
517 );
518 self
519 }
520
521 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
523 self.handle_controller = Some(controller);
524 self
525 }
526
527 fn create_pan_resolver(
535 state: TextFieldState,
536 refs: TextFieldRefs,
537 line_limits: TextFieldLineLimits,
538 style: TextStyle,
539 ) -> TextPanResolver {
540 Rc::new(move |viewport_width: f32| {
541 if !line_limits.is_single_line() {
542 refs.scroll_offset.set(0.0);
544 return 0.0;
545 }
546 let text = state.text();
547 let pos = state.selection().start.min(text.len());
548 let text_width = crate::text::measure_text(
549 &crate::text::AnnotatedString::from(text.as_str()),
550 &style,
551 )
552 .width;
553 let cursor_x = crate::text::measure_text(
554 &crate::text::AnnotatedString::from(&text[..pos]),
555 &style,
556 )
557 .width;
558 let offset = compute_horizontal_scroll_offset(
559 refs.scroll_offset.get(),
560 cursor_x,
561 text_width,
562 viewport_width,
563 );
564 refs.scroll_offset.set(offset);
565 offset
566 })
567 }
568
569 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
574 self.line_limits
575 .is_single_line()
576 .then(|| self.cached_pan_resolver.clone())
577 }
578
579 pub fn scroll_offset(&self) -> f32 {
581 self.refs.scroll_offset.get()
582 }
583
584 pub fn line_limits(&self) -> TextFieldLineLimits {
586 self.line_limits
587 }
588
589 fn create_handler(
591 state: TextFieldState,
592 refs: TextFieldRefs,
593 line_limits: TextFieldLineLimits,
594 style: TextStyle, ) -> Rc<dyn Fn(PointerEvent)> {
596 use crate::text_selection::{
599 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
600 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
601 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
602 };
603 use crate::word_boundaries::find_word_boundaries;
604
605 Rc::new(move |event: PointerEvent| {
606 refs.node_origin.set(Point {
613 x: event.global_position.x - event.position.x,
614 y: event.global_position.y - event.position.y,
615 });
616
617 let click_x =
621 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
622 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
623
624 match event.kind {
625 PointerEventKind::Down => {
626 refs.direct_manipulation.set(true);
630 refs.press_track.set(Some(PointerPressTrack {
631 start: event.global_position,
632 position: event.global_position,
633 }));
634 refs.gesture_claimed.set(false);
635
636 let handler = TextFieldHandler::new(
641 state.clone(),
642 refs.node_id.get(),
643 line_limits,
644 crate::text_field_handler::CaretGeometryRefs {
645 node_origin: refs.node_origin.clone(),
646 content_offset: refs.content_offset.clone(),
647 content_y_offset: refs.content_y_offset.clone(),
648 scroll_offset: refs.scroll_offset.clone(),
649 style: style.clone(),
650 },
651 );
652 crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
653
654 let now = web_time::Instant::now();
655 let text = state.text();
656 let pos = crate::text::offset_for_position_wrapped(
657 &text,
658 &style,
659 refs.node_id.get(),
660 refs.wrap_width.get(),
661 refs.line_height.get(),
662 click_x,
663 click_y,
664 );
665
666 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
671 let count = refs.click_count.get();
672 (count > 0).then_some((count, px, py))
673 });
674 let elapsed_ms = refs
675 .last_click_time
676 .get()
677 .map(|last| now.duration_since(last).as_millis())
678 .unwrap_or(u128::MAX);
679 let tap_count = classify_tap_count(
680 previous,
681 elapsed_ms,
682 event.position.x,
683 event.position.y,
684 MULTI_TAP_TIMEOUT_MS,
685 MULTI_TAP_SLOP_PX,
686 );
687
688 let selection = state.selection();
696 let tap_in_selection =
697 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
698 let repeat_in_place = refs
701 .last_click_pos
702 .get()
703 .map(|(px, py)| {
704 let dx = event.position.x - px;
705 let dy = event.position.y - py;
706 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
707 })
708 .unwrap_or(false);
709 let effective_count = resolve_selection_tap_count(
710 tap_count,
711 refs.click_count.get(),
712 tap_in_selection,
713 repeat_in_place,
714 );
715
716 match tap_selection_granularity(effective_count) {
717 SelectionGranularity::Paragraph => {
718 let (start, end) = find_paragraph_boundaries(&text, pos);
720 state.edit(|buffer| {
721 buffer.select(TextRange::new(start, end));
722 });
723 refs.drag_anchor.set(Some(start));
724 }
725 SelectionGranularity::Line => {
726 let (line_start, line_end) = find_line_boundaries(&text, pos);
728 state.edit(|buffer| {
729 buffer.select(TextRange::new(line_start, line_end));
730 });
731 refs.drag_anchor.set(Some(line_start));
732 }
733 SelectionGranularity::Word => {
734 let (word_start, word_end) = find_word_boundaries(&text, pos);
737 state.edit(|buffer| {
738 buffer.select(TextRange::new(word_start, word_end));
739 });
740 refs.drag_anchor.set(Some(word_start));
741 }
742 SelectionGranularity::Caret => {
743 refs.drag_anchor.set(Some(pos));
745 state.edit(|buffer| {
746 buffer.place_cursor_before_char(pos);
747 });
748 }
749 }
750
751 refs.click_count.set(effective_count);
752 refs.last_click_time.set(Some(now));
753 refs.last_click_pos
754 .set(Some((event.position.x, event.position.y)));
755 event.consume();
756 }
757 PointerEventKind::Move => {
758 if let Some(mut track) = refs.press_track.get() {
760 track.position = event.global_position;
761 refs.press_track.set(Some(track));
762 crate::request_render_invalidation();
763 }
764 if refs.gesture_claimed.get() {
767 event.consume();
768 return;
769 }
770 if let Some(anchor) = refs.drag_anchor.get() {
772 if *refs.is_focused.borrow() {
773 let text = state.text();
774 let current_pos = crate::text::offset_for_position_wrapped(
775 &text,
776 &style,
777 refs.node_id.get(),
778 refs.wrap_width.get(),
779 refs.line_height.get(),
780 click_x,
781 click_y,
782 );
783
784 state.set_selection(TextRange::new(anchor, current_pos));
786
787 crate::request_render_invalidation();
789
790 event.consume();
791 }
792 }
793 }
794 PointerEventKind::Up => {
795 refs.drag_anchor.set(None);
797 refs.press_track.set(None);
798 refs.gesture_claimed.set(false);
799 }
800 PointerEventKind::Cancel => {
801 refs.press_track.set(None);
802 refs.gesture_claimed.set(false);
803 }
804 _ => {}
805 }
806 })
807 }
808
809 pub fn with_cursor_color(mut self, color: Color) -> Self {
814 self.cursor_brush = Brush::solid(color);
815 self.selection_brush = Brush::solid(
816 color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
817 );
818 self
819 }
820
821 pub fn set_focused(&mut self, focused: bool) {
823 let current = *self.refs.is_focused.borrow();
824 if current != focused {
825 *self.refs.is_focused.borrow_mut() = focused;
826 if !focused {
827 self.refs.direct_manipulation.set(false);
828 self.refs.press_track.set(None);
829 self.refs.gesture_claimed.set(false);
830 }
831 }
832 }
833
834 pub fn is_focused(&self) -> bool {
836 *self.refs.is_focused.borrow()
837 }
838
839 pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
841 self.refs.is_focused.clone()
842 }
843
844 pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
846 self.refs.content_offset.clone()
847 }
848
849 pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
851 self.refs.content_y_offset.clone()
852 }
853
854 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
867 self.refs.node_origin.clone()
868 }
869
870 pub fn text(&self) -> String {
872 self.state.text()
873 }
874
875 pub fn style(&self) -> &TextStyle {
876 &self.style
877 }
878
879 pub fn selection(&self) -> TextRange {
881 self.state.selection()
882 }
883
884 pub fn cursor_brush(&self) -> Brush {
886 self.cursor_brush.clone()
887 }
888
889 pub fn selection_brush(&self) -> Brush {
891 self.selection_brush.clone()
892 }
893
894 pub fn insert_text(&mut self, text: &str) {
896 self.state.edit(|buffer| {
897 buffer.insert(text);
898 });
899 }
900
901 pub fn copy_selection(&self) -> Option<String> {
904 self.state.copy_selection()
905 }
906
907 pub fn cut_selection(&mut self) -> Option<String> {
910 let text = self.copy_selection();
911 if text.is_some() {
912 self.state.edit(|buffer| {
913 buffer.delete(buffer.selection());
914 });
915 }
916 text
917 }
918
919 pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
922 self.state.clone()
923 }
924
925 pub fn set_content_offset(&self, offset: f32) {
928 self.refs.content_offset.set(offset);
929 }
930
931 pub fn set_content_y_offset(&self, offset: f32) {
934 self.refs.content_y_offset.set(offset);
935 }
936
937 fn wrap_width(&self, available_width: f32) -> Option<f32> {
944 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
945 .then_some(available_width)
946 }
947
948 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
954 let text = self.state.text();
955 let node_id = self.refs.node_id.get();
956 let annotated = crate::text::AnnotatedString::from(text.as_str());
957 let metrics = match wrap_width {
958 Some(max_width) => crate::text::measure_text_with_options_for_node(
959 node_id,
960 &annotated,
961 &self.style,
962 crate::text::TextLayoutOptions::default(),
963 Some(max_width),
964 ),
965 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
966 };
967 self.measured_line_height.set(metrics.line_height);
968 Size {
969 width: metrics.width,
970 height: metrics.height,
971 }
972 }
973
974 fn update_cached_state(&mut self) -> bool {
976 let value = self.state.value();
977 let text_changed = value.text != self.cached_text;
978 let selection_changed = value.selection != self.cached_selection;
979
980 if text_changed {
981 self.cached_text = value.text;
982 }
983 if selection_changed {
984 self.cached_selection = value.selection;
985 }
986
987 text_changed || selection_changed
988 }
989
990 pub fn position_cursor_at_offset(&self, x_offset: f32) {
993 let text = self.state.text();
994 if text.is_empty() {
995 self.state.edit(|buffer| {
996 buffer.place_cursor_at_start();
997 });
998 return;
999 }
1000
1001 let byte_offset = crate::text::get_offset_for_position(
1004 &crate::text::AnnotatedString::from(text.as_str()),
1005 &self.style,
1006 x_offset + self.refs.scroll_offset.get(),
1007 0.0,
1008 );
1009
1010 self.state.edit(|buffer| {
1011 buffer.place_cursor_before_char(byte_offset);
1012 });
1013 }
1014
1015 }
1019
1020impl DelegatableNode for TextFieldModifierNode {
1021 fn node_state(&self) -> &NodeState {
1022 &self.node_state
1023 }
1024}
1025
1026impl ModifierNode for TextFieldModifierNode {
1027 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
1028 self.refs.node_id.set(context.node_id());
1030
1031 context.invalidate(InvalidationKind::Layout);
1032 context.invalidate(InvalidationKind::Draw);
1033 context.invalidate(InvalidationKind::Semantics);
1034 }
1035
1036 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
1037 Some(self)
1038 }
1039
1040 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
1041 Some(self)
1042 }
1043
1044 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
1045 Some(self)
1046 }
1047
1048 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
1049 Some(self)
1050 }
1051
1052 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
1053 Some(self)
1054 }
1055
1056 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
1057 Some(self)
1058 }
1059
1060 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
1061 Some(self)
1062 }
1063
1064 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
1065 Some(self)
1066 }
1067}
1068
1069impl LayoutModifierNode for TextFieldModifierNode {
1070 fn measure(
1071 &self,
1072 _context: &mut dyn ModifierNodeContext,
1073 _measurable: &dyn Measurable,
1074 constraints: Constraints,
1075 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
1076 let wrap_width = self.wrap_width(constraints.max_width);
1080 self.measured_wrap_width.set(wrap_width);
1083 let text_size = self.measure_text_content(wrap_width);
1084
1085 let min_height = if text_size.height < 1.0 {
1087 DEFAULT_LINE_HEIGHT
1088 } else {
1089 text_size.height
1090 };
1091
1092 let width = text_size
1094 .width
1095 .max(constraints.min_width)
1096 .min(constraints.max_width);
1097 let height = min_height
1098 .max(constraints.min_height)
1099 .min(constraints.max_height);
1100
1101 let size = Size { width, height };
1102 self.measured_size.set(size);
1103
1104 let _ = (self.cached_pan_resolver)(size.width);
1107
1108 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
1109 }
1110
1111 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1112 self.measure_text_content(None).width
1113 }
1114
1115 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1116 self.measure_text_content(None).width
1117 }
1118
1119 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1120 self.measure_text_content(self.wrap_width(width))
1121 .height
1122 .max(DEFAULT_LINE_HEIGHT)
1123 }
1124
1125 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1126 self.measure_text_content(self.wrap_width(width))
1127 .height
1128 .max(DEFAULT_LINE_HEIGHT)
1129 }
1130}
1131
1132fn content_viewport(
1136 measured: cranpose_ui_graphics::Size,
1137 size: cranpose_foundation::Size,
1138 padding_left: f32,
1139 padding_top: f32,
1140) -> (f32, f32) {
1141 let width = if measured.width > 0.0 {
1142 measured.width
1143 } else {
1144 (size.width - padding_left).max(0.0)
1145 };
1146 let height = if measured.height > 0.0 {
1147 measured.height
1148 } else {
1149 (size.height - padding_top).max(0.0)
1150 };
1151 (width, height)
1152}
1153
1154impl DrawModifierNode for TextFieldModifierNode {
1155 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
1156 }
1160
1161 fn create_draw_closure(
1162 &self,
1163 ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
1164 {
1165 use cranpose_ui_graphics::DrawPrimitive;
1166
1167 let is_focused = self.refs.is_focused.clone();
1169 let state = self.state.clone();
1170 let content_offset = self.refs.content_offset.clone();
1171 let content_y_offset = self.refs.content_y_offset.clone();
1172 let cursor_brush = self.cursor_brush.clone();
1173 let style = self.style.clone();
1174 let cached_line_height = self.measured_line_height.clone();
1175 let measured_size = self.measured_size.clone();
1176 let measured_wrap_width = self.measured_wrap_width.clone();
1177 let node_id = self.refs.node_id.clone();
1178 let pan_resolver = self.cached_pan_resolver.clone();
1179 let handle_controller = self.handle_controller.clone();
1180 let node_origin = self.refs.node_origin.clone();
1181 let direct_manipulation = self.refs.direct_manipulation.clone();
1182 let press_track = self.refs.press_track.clone();
1183 let gesture_claimed = self.refs.gesture_claimed.clone();
1184
1185 Some(Rc::new(move |size| {
1186 if !*is_focused.borrow() {
1188 if let Some(controller) = &handle_controller {
1191 controller.publish(TextFieldHandleMetrics {
1192 focused: false,
1193 direct_manipulation: false,
1194 node_origin: node_origin.get(),
1195 padding_left: 0.0,
1196 padding_top: 0.0,
1197 scroll_offset: 0.0,
1198 line_height: cached_line_height.get(),
1199 glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
1200 wrap_width: measured_wrap_width.get(),
1201 press: None,
1202 });
1203 }
1204 return vec![];
1205 }
1206
1207 let mut primitives = Vec::new();
1208
1209 let text = state.text();
1210 let selection = state.selection();
1211 let padding_left = content_offset.get();
1212 let padding_top = content_y_offset.get();
1213 let line_height = cached_line_height.get();
1216
1217 let (viewport_width, viewport_height) =
1218 content_viewport(measured_size.get(), size, padding_left, padding_top);
1219 let pan = pan_resolver(viewport_width);
1221
1222 if let Some(controller) = &handle_controller {
1225 controller.adopt_gesture_claim(&gesture_claimed);
1226 controller.publish(TextFieldHandleMetrics {
1227 focused: true,
1228 direct_manipulation: direct_manipulation.get(),
1229 node_origin: node_origin.get(),
1230 padding_left,
1231 padding_top,
1232 scroll_offset: pan,
1233 line_height,
1234 glyph_box: crate::text::glyph_line_box(&style, line_height),
1235 wrap_width: measured_wrap_width.get(),
1236 press: press_track.get(),
1237 });
1238 }
1239 let clip_bounds = cranpose_ui_graphics::Rect {
1243 x: padding_left,
1244 y: padding_top,
1245 width: viewport_width,
1246 height: viewport_height,
1247 };
1248
1249 if let Some(comp_range) = state.composition() {
1256 let comp_start = comp_range.min();
1257 let comp_end = comp_range.max();
1258
1259 if comp_start < comp_end && comp_end <= text.len() {
1260 let underline_brush = cranpose_ui_graphics::Brush::solid(
1262 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1263 );
1264 let underline_height: f32 = 2.0;
1265
1266 for line_rect in range_visual_line_rects(
1269 &text,
1270 &style,
1271 node_id.get(),
1272 measured_wrap_width.get(),
1273 padding_left,
1274 padding_top,
1275 pan,
1276 line_height,
1277 comp_start,
1278 comp_end,
1279 ) {
1280 let underline_rect = cranpose_ui_graphics::Rect {
1281 x: line_rect.x,
1282 y: line_rect.y + line_height - underline_height,
1283 width: line_rect.width,
1284 height: underline_height,
1285 };
1286 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1287 primitives.push(DrawPrimitive::Rect {
1288 rect: clipped,
1289 brush: underline_brush.clone(),
1290 });
1291 }
1292 }
1293 }
1294 }
1295
1296 if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1302 let pos = selection.start.min(text.len());
1303 let (line_index, line_start) = caret_visual_line_for_offset(
1312 &text,
1313 &style,
1314 node_id.get(),
1315 measured_wrap_width.get(),
1316 pos,
1317 crate::text_selection::LineAffinity::Upstream,
1318 );
1319 let cursor_x = crate::text::measure_text(
1320 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1321 &style,
1322 )
1323 .width
1324 + padding_left
1325 - pan;
1326 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1329 let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1330
1331 let cursor_rect = cranpose_ui_graphics::Rect {
1332 x: cursor_x,
1333 y: cursor_y,
1334 width: CURSOR_WIDTH,
1335 height: box_h,
1336 };
1337
1338 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1339 primitives.push(DrawPrimitive::Rect {
1340 rect: clipped,
1341 brush: cursor_brush.clone(),
1342 });
1343 }
1344 }
1345
1346 primitives
1347 }))
1348 }
1349
1350 fn create_behind_draw_closure(
1351 &self,
1352 ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
1353 {
1354 use cranpose_ui_graphics::DrawPrimitive;
1355
1356 let is_focused = self.refs.is_focused.clone();
1357 let state = self.state.clone();
1358 let content_offset = self.refs.content_offset.clone();
1359 let content_y_offset = self.refs.content_y_offset.clone();
1360 let selection_brush = self.selection_brush.clone();
1361 let style = self.style.clone();
1362 let cached_line_height = self.measured_line_height.clone();
1363 let measured_size = self.measured_size.clone();
1364 let measured_wrap_width = self.measured_wrap_width.clone();
1365 let node_id = self.refs.node_id.clone();
1366 let pan_resolver = self.cached_pan_resolver.clone();
1367
1368 Some(Rc::new(move |size| {
1369 if !*is_focused.borrow() {
1370 return vec![];
1371 }
1372 let selection = state.selection();
1373 if selection.collapsed() {
1374 return vec![];
1375 }
1376 let text = state.text();
1377 let padding_left = content_offset.get();
1378 let padding_top = content_y_offset.get();
1379 let line_height = cached_line_height.get();
1380 let (viewport_width, viewport_height) =
1381 content_viewport(measured_size.get(), size, padding_left, padding_top);
1382 let pan = pan_resolver(viewport_width);
1383 let clip_bounds = cranpose_ui_graphics::Rect {
1384 x: padding_left,
1385 y: padding_top,
1386 width: viewport_width,
1387 height: viewport_height,
1388 };
1389
1390 let mut primitives = Vec::new();
1394 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1398 for sel_rect in range_visual_line_rects(
1399 &text,
1400 &style,
1401 node_id.get(),
1402 measured_wrap_width.get(),
1403 padding_left,
1404 padding_top,
1405 pan,
1406 line_height,
1407 selection.min(),
1408 selection.max(),
1409 ) {
1410 let sel_rect = cranpose_ui_graphics::Rect {
1411 y: sel_rect.y + box_off,
1412 height: box_h,
1413 ..sel_rect
1414 };
1415 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1416 primitives.push(DrawPrimitive::Rect {
1417 rect: clipped,
1418 brush: selection_brush.clone(),
1419 });
1420 }
1421 }
1422 primitives
1423 }))
1424 }
1425}
1426
1427impl SemanticsNode for TextFieldModifierNode {
1428 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1429 let text = self.state.text();
1430 config.content_description = Some(text);
1431 config.is_editable_text = true;
1432 config.text_selection = Some(self.state.selection());
1433 }
1434}
1435
1436impl PointerInputNode for TextFieldModifierNode {
1437 fn on_pointer_event(
1438 &mut self,
1439 _context: &mut dyn ModifierNodeContext,
1440 _event: &PointerEvent,
1441 ) -> bool {
1442 false
1453 }
1454
1455 fn hit_test(&self, x: f32, y: f32) -> bool {
1456 let size = self.measured_size.get();
1458 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1459 }
1460
1461 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1462 Some(self.cached_handler.clone())
1464 }
1465}
1466
1467#[derive(Clone)]
1478pub struct TextFieldElement {
1479 state: TextFieldState,
1481 style: TextStyle,
1483 cursor_color: Color,
1485 line_limits: TextFieldLineLimits,
1487 handle_controller: Option<TextFieldHandleController>,
1490}
1491
1492impl TextFieldElement {
1493 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1495 Self {
1496 state,
1497 style,
1498 cursor_color: DEFAULT_CURSOR_COLOR,
1499 line_limits: TextFieldLineLimits::default(),
1500 handle_controller: None,
1501 }
1502 }
1503
1504 pub fn with_cursor_color(mut self, color: Color) -> Self {
1506 self.cursor_color = color;
1507 self
1508 }
1509
1510 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1512 self.line_limits = line_limits;
1513 self
1514 }
1515
1516 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1518 self.handle_controller = Some(controller);
1519 self
1520 }
1521}
1522
1523impl std::fmt::Debug for TextFieldElement {
1524 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1525 f.debug_struct("TextFieldElement")
1526 .field("text", &self.state.text())
1527 .field("style", &self.style)
1528 .field("cursor_color", &self.cursor_color)
1529 .finish()
1530 }
1531}
1532
1533impl Hash for TextFieldElement {
1534 fn hash<H: Hasher>(&self, state: &mut H) {
1535 std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1538 self.cursor_color.0.to_bits().hash(state);
1540 self.cursor_color.1.to_bits().hash(state);
1541 self.cursor_color.2.to_bits().hash(state);
1542 self.cursor_color.3.to_bits().hash(state);
1543 self.style.render_hash().hash(state);
1544 self.line_limits.hash(state);
1545 }
1546}
1547
1548impl PartialEq for TextFieldElement {
1549 fn eq(&self, other: &Self) -> bool {
1550 self.state == other.state
1554 && self.style == other.style
1555 && self.cursor_color == other.cursor_color
1556 && self.line_limits == other.line_limits
1557 }
1558}
1559
1560impl Eq for TextFieldElement {}
1561
1562impl ModifierNodeElement for TextFieldElement {
1563 type Node = TextFieldModifierNode;
1564
1565 fn create(&self) -> Self::Node {
1566 let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1567 .with_cursor_color(self.cursor_color)
1568 .with_line_limits(self.line_limits);
1569 if let Some(controller) = self.handle_controller.clone() {
1570 node = node.with_handle_controller(controller);
1571 }
1572 node
1573 }
1574
1575 fn update(&self, node: &mut Self::Node) {
1576 node.state = self.state.clone();
1578 node.style = self.style.clone();
1579 node.cursor_brush = Brush::solid(self.cursor_color);
1580 node.line_limits = self.line_limits;
1581 node.handle_controller = self.handle_controller.clone();
1582
1583 node.cached_handler = TextFieldModifierNode::create_handler(
1585 node.state.clone(),
1586 node.refs.clone(),
1587 node.line_limits,
1588 self.style.clone(),
1589 );
1590
1591 node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1593 node.state.clone(),
1594 node.refs.clone(),
1595 node.line_limits,
1596 self.style.clone(),
1597 );
1598
1599 if node.update_cached_state() {
1601 }
1604 }
1605
1606 fn capabilities(&self) -> NodeCapabilities {
1607 NodeCapabilities::LAYOUT
1608 | NodeCapabilities::DRAW
1609 | NodeCapabilities::SEMANTICS
1610 | NodeCapabilities::POINTER_INPUT
1611 }
1612
1613 fn always_update(&self) -> bool {
1614 true
1616 }
1617}
1618
1619#[cfg(test)]
1620mod tests {
1621 use super::*;
1622 use crate::text::TextStyle;
1623 use cranpose_core::{DefaultScheduler, Runtime};
1624 use std::sync::Arc;
1625
1626 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1628 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1629 f()
1630 }
1631
1632 #[test]
1633 fn text_field_node_creation() {
1634 let _app_context = crate::render_state::app_context_test_scope();
1635 with_test_runtime(|| {
1636 let state = TextFieldState::new("Hello");
1637 let node = TextFieldModifierNode::new(state, TextStyle::default());
1638 assert_eq!(node.text(), "Hello");
1639 assert!(!node.is_focused());
1640 });
1641 }
1642
1643 #[test]
1648 fn selection_rects_follow_wrapped_visual_lines() {
1649 let _app_context = crate::render_state::app_context_test_scope();
1650 let text = "aaaaa\nbb";
1653 let style = TextStyle::default();
1654 let line_height = 10.0_f32;
1655
1656 let rects = range_visual_line_rects(
1659 text,
1660 &style,
1661 None,
1662 Some(30.0),
1663 0.0,
1664 0.0,
1665 0.0,
1666 line_height,
1667 6,
1668 8,
1669 );
1670 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1671 assert_eq!(
1672 rects[0].y,
1673 2.0 * line_height,
1674 "highlight must land on visual line 2, not logical line 1"
1675 );
1676 assert!(rects[0].width > 0.0);
1677
1678 let spanning = range_visual_line_rects(
1681 text,
1682 &style,
1683 None,
1684 Some(30.0),
1685 0.0,
1686 0.0,
1687 0.0,
1688 line_height,
1689 0,
1690 5,
1691 );
1692 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1693 assert_eq!(spanning[0].y, 0.0);
1694 assert_eq!(spanning[1].y, line_height);
1695 }
1696
1697 #[test]
1702 fn tap_resolves_offset_on_wrapped_visual_line() {
1703 let _app_context = crate::render_state::app_context_test_scope();
1704 let text = "aaaaa\nbb";
1707 let style = TextStyle::default();
1708 let line_height = 10.0_f32;
1709
1710 let off = crate::text::offset_for_position_wrapped(
1713 text,
1714 &style,
1715 None,
1716 Some(30.0),
1717 line_height,
1718 8.0,
1719 22.0,
1720 );
1721 assert!(
1722 (6..=8).contains(&off),
1723 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1724 );
1725
1726 let off1 = crate::text::offset_for_position_wrapped(
1729 text,
1730 &style,
1731 None,
1732 Some(30.0),
1733 line_height,
1734 4.0,
1735 12.0,
1736 );
1737 assert!(
1738 (3..=5).contains(&off1),
1739 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1740 );
1741
1742 let off2 = crate::text::offset_for_position_wrapped(
1744 "hello",
1745 &style,
1746 None,
1747 None,
1748 line_height,
1749 0.0,
1750 0.0,
1751 );
1752 assert_eq!(off2, 0);
1753 }
1754
1755 #[test]
1756 fn text_field_node_focus() {
1757 let _app_context = crate::render_state::app_context_test_scope();
1758 with_test_runtime(|| {
1759 let state = TextFieldState::new("Test");
1760 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1761 assert!(!node.is_focused());
1762
1763 node.set_focused(true);
1764 assert!(node.is_focused());
1765
1766 node.set_focused(false);
1767 assert!(!node.is_focused());
1768 });
1769 }
1770
1771 #[test]
1772 fn text_field_element_creates_node() {
1773 let _app_context = crate::render_state::app_context_test_scope();
1774 with_test_runtime(|| {
1775 let state = TextFieldState::new("Hello World");
1776 let element = TextFieldElement::new(state, TextStyle::default());
1777
1778 let node = element.create();
1779 assert_eq!(node.text(), "Hello World");
1780 });
1781 }
1782
1783 #[test]
1788 fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1789 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1790 use cranpose_ui_graphics::Point;
1791
1792 let _app_context = crate::render_state::app_context_test_scope();
1793 with_test_runtime(|| {
1794 let state = TextFieldState::new("hello world");
1795 let controller = TextFieldHandleController::new();
1796 let mut node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1797 .with_handle_controller(controller.clone());
1798 node.measured_size.set(Size {
1800 width: 120.0,
1801 height: 20.0,
1802 });
1803
1804 let handler = node
1805 .pointer_input_handler()
1806 .expect("field exposes a pointer handler");
1807 let draw = node
1808 .create_draw_closure()
1809 .expect("field exposes a draw closure");
1810 let at = Point { x: 12.0, y: 8.0 };
1811 let size = Size {
1812 width: 120.0,
1813 height: 20.0,
1814 };
1815
1816 node.set_focused(true);
1817 let _ = draw(size);
1818 let keyboard_metrics = controller
1819 .metrics()
1820 .expect("focused field publishes handle metrics");
1821 assert!(!keyboard_metrics.direct_manipulation);
1822
1823 handler(
1824 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1825 );
1826 let _ = draw(size);
1827 let metrics = controller
1828 .metrics()
1829 .expect("focused field publishes handle metrics");
1830 assert!(metrics.focused, "a tap focuses the field");
1831 assert!(
1832 metrics.direct_manipulation,
1833 "a touch tap must expose direct-manipulation handles"
1834 );
1835 assert!(metrics.press.is_some(), "touch must publish the live press");
1836
1837 handler(
1838 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1839 );
1840 let _ = draw(size);
1841 let metrics = controller
1842 .metrics()
1843 .expect("focused field publishes handle metrics");
1844 assert!(
1845 metrics.direct_manipulation,
1846 "a mouse tap must expose the same direct-manipulation handles"
1847 );
1848 assert!(metrics.press.is_some(), "mouse must publish the live press");
1849
1850 handler(
1851 PointerEvent::new(PointerEventKind::Down, at, at)
1852 .with_source(PointerSource::Stylus),
1853 );
1854 let _ = draw(size);
1855 let metrics = controller
1856 .metrics()
1857 .expect("focused field publishes handle metrics");
1858 assert!(
1859 metrics.direct_manipulation,
1860 "a stylus contact must expose the same direct-manipulation handles"
1861 );
1862 assert!(
1863 metrics.press.is_some(),
1864 "stylus must publish the live press"
1865 );
1866
1867 crate::text_field_focus::clear_focus();
1868 });
1869 }
1870
1871 #[test]
1879 fn double_tap_selects_the_word_under_the_finger() {
1880 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1881 use cranpose_ui_graphics::Point;
1882
1883 let _app_context = crate::render_state::app_context_test_scope();
1884 with_test_runtime(|| {
1885 let state = TextFieldState::new("hello world");
1886 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1887 node.measured_size.set(Size {
1888 width: 200.0,
1889 height: 20.0,
1890 });
1891 let handler = node
1892 .pointer_input_handler()
1893 .expect("field exposes a pointer handler");
1894
1895 let at = Point { x: 2.0, y: 8.0 };
1898 handler(
1899 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1900 );
1901 handler(
1902 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1903 );
1904
1905 let selection = state.selection();
1906 assert!(
1907 !selection.collapsed(),
1908 "a double tap must produce a (word) selection, got {selection:?}"
1909 );
1910 let selected = &state.text()[selection.min()..selection.max()];
1911 assert_eq!(
1912 selected, "hello",
1913 "double tap should select the whole word under the finger"
1914 );
1915
1916 crate::text_field_focus::clear_focus();
1917 });
1918 }
1919
1920 #[test]
1924 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1925 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1926 use cranpose_ui_graphics::Point;
1927
1928 let _app_context = crate::render_state::app_context_test_scope();
1929 with_test_runtime(|| {
1930 let text = "alpha beta\ngamma delta\n\nsecond para";
1933 let state = TextFieldState::new(text);
1934 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1935 .with_line_limits(TextFieldLineLimits::MultiLine {
1936 min_lines: 1,
1937 max_lines: usize::MAX,
1938 });
1939 node.measured_size.set(Size {
1940 width: 400.0,
1941 height: 80.0,
1942 });
1943 let handler = node
1944 .pointer_input_handler()
1945 .expect("field exposes a pointer handler");
1946
1947 let at = Point { x: 2.0, y: 4.0 };
1949 let tap = || {
1950 handler(
1951 PointerEvent::new(PointerEventKind::Down, at, at)
1952 .with_source(PointerSource::Touch),
1953 );
1954 };
1955 let selected = |state: &TextFieldState| {
1956 let s = state.selection();
1957 state.text()[s.min()..s.max()].to_string()
1958 };
1959
1960 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1962 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1964 tap(); assert_eq!(
1966 selected(&state),
1967 "alpha beta",
1968 "triple tap selects the line"
1969 );
1970 tap(); assert_eq!(
1972 selected(&state),
1973 "alpha beta\ngamma delta",
1974 "fourth tap grows to the paragraph"
1975 );
1976 tap(); assert_eq!(
1978 selected(&state),
1979 "alpha",
1980 "fifth tap cycles back to the word"
1981 );
1982
1983 crate::text_field_focus::clear_focus();
1984 });
1985 }
1986
1987 #[test]
1991 fn single_tap_inside_selection_selects_the_word() {
1992 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1993 use cranpose_ui_graphics::Point;
1994
1995 let _app_context = crate::render_state::app_context_test_scope();
1996 with_test_runtime(|| {
1997 let state = TextFieldState::new("hello world");
1998 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1999 node.measured_size.set(Size {
2000 width: 200.0,
2001 height: 20.0,
2002 });
2003 let handler = node
2004 .pointer_input_handler()
2005 .expect("field exposes a pointer handler");
2006
2007 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
2009 assert!(!state.selection().collapsed());
2010
2011 let at = Point { x: 2.0, y: 8.0 };
2014 handler(
2015 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
2016 );
2017
2018 let selection = state.selection();
2019 assert!(
2020 !selection.collapsed(),
2021 "a tap inside a selection must not collapse it, got {selection:?}"
2022 );
2023 assert_eq!(
2024 &state.text()[selection.min()..selection.max()],
2025 "hello",
2026 "a tap inside a selection re-selects the word under the finger"
2027 );
2028
2029 crate::text_field_focus::clear_focus();
2030 });
2031 }
2032
2033 #[test]
2041 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
2042 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2043 use cranpose_ui_graphics::Point;
2044
2045 let _app_context = crate::render_state::app_context_test_scope();
2046 with_test_runtime(|| {
2047 let text = "alpha beta\ngamma delta\n\nsecond para";
2048 let state = TextFieldState::new(text);
2049 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
2050 .with_line_limits(TextFieldLineLimits::MultiLine {
2051 min_lines: 1,
2052 max_lines: usize::MAX,
2053 });
2054 node.measured_size.set(Size {
2055 width: 400.0,
2056 height: 80.0,
2057 });
2058 let handler = node
2059 .pointer_input_handler()
2060 .expect("field exposes a pointer handler");
2061
2062 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
2064
2065 let at = Point { x: 2.0, y: 4.0 };
2066 let selected = |state: &TextFieldState| {
2067 let s = state.selection();
2068 state.text()[s.min()..s.max()].to_string()
2069 };
2070 let slow_tap = || {
2073 node.refs.last_click_time.set(None);
2074 handler(
2075 PointerEvent::new(PointerEventKind::Down, at, at)
2076 .with_source(PointerSource::Touch),
2077 );
2078 };
2079
2080 slow_tap(); assert_eq!(
2082 selected(&state),
2083 "alpha",
2084 "tap inside selection grabs the word"
2085 );
2086 slow_tap(); assert_eq!(
2088 selected(&state),
2089 "alpha beta",
2090 "same-spot tap grows to the line even after the timeout"
2091 );
2092 slow_tap(); assert_eq!(
2094 selected(&state),
2095 "alpha beta\ngamma delta",
2096 "same-spot tap grows to the paragraph"
2097 );
2098 slow_tap(); assert_eq!(
2100 selected(&state),
2101 "alpha",
2102 "same-spot tap cycles back to the word"
2103 );
2104
2105 crate::text_field_focus::clear_focus();
2106 });
2107 }
2108
2109 #[test]
2110 fn text_field_element_equality() {
2111 let _app_context = crate::render_state::app_context_test_scope();
2112 with_test_runtime(|| {
2113 let state1 = TextFieldState::new("Hello");
2114 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
2117 let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
2123 assert_ne!(elem1, elem3, "Different states should not be equal");
2124 });
2125 }
2126
2127 #[test]
2128 fn text_field_element_update_refreshes_existing_node_style() {
2129 let _app_context = crate::render_state::app_context_test_scope();
2130 with_test_runtime(|| {
2131 let state = TextFieldState::new("themed text");
2132 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
2133 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
2134 ..crate::text::SpanStyle::default()
2135 });
2136 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
2137 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
2138 ..crate::text::SpanStyle::default()
2139 });
2140 let initial = TextFieldElement::new(state.clone(), dark_style);
2141 let updated = TextFieldElement::new(state, light_style.clone());
2142 let mut node = initial.create();
2143
2144 updated.update(&mut node);
2145
2146 assert_eq!(node.text(), "themed text");
2147 assert_eq!(node.style(), &light_style);
2148 });
2149 }
2150
2151 #[test]
2156 fn multiline_field_measures_wrapped_height() {
2157 let _app_context = crate::render_state::app_context_test_scope();
2158 with_test_runtime(|| {
2159 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
2161 let node = TextFieldModifierNode::new(state, TextStyle::default());
2162 assert!(
2163 !node.line_limits().is_single_line(),
2164 "default fields are multi-line"
2165 );
2166
2167 let natural = node.measure_text_content(None);
2168 let wrapped = node.measure_text_content(node.wrap_width(20.0));
2169
2170 assert!(
2171 wrapped.height > natural.height,
2172 "wrapped multi-line height {} must exceed the single-line height {}",
2173 wrapped.height,
2174 natural.height
2175 );
2176 });
2177 }
2178
2179 #[test]
2182 fn single_line_field_never_wraps() {
2183 let _app_context = crate::render_state::app_context_test_scope();
2184 with_test_runtime(|| {
2185 let state = TextFieldState::new("abcd ".repeat(40));
2186 let node = TextFieldModifierNode::new(state, TextStyle::default())
2187 .with_line_limits(TextFieldLineLimits::SingleLine);
2188 assert_eq!(
2189 node.wrap_width(20.0),
2190 None,
2191 "single-line fields must not wrap"
2192 );
2193 });
2194 }
2195
2196 #[test]
2202 fn test_cursor_x_position_calculation() {
2203 let _app_context = crate::render_state::app_context_test_scope();
2204 with_test_runtime(|| {
2205 let style = crate::text::TextStyle::default();
2207
2208 let empty_width =
2210 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2211 assert!(
2212 empty_width.abs() < 0.1,
2213 "Empty text should have 0 width, got {}",
2214 empty_width
2215 );
2216
2217 let hi_width =
2219 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2220 assert!(
2221 hi_width > 0.0,
2222 "Text 'Hi' should have positive width: {}",
2223 hi_width
2224 );
2225
2226 let h_width =
2228 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2229 assert!(h_width > 0.0, "Text 'H' should have positive width");
2230 assert!(
2231 h_width < hi_width,
2232 "'H' width {} should be less than 'Hi' width {}",
2233 h_width,
2234 hi_width
2235 );
2236
2237 let state = TextFieldState::new("Hi");
2239 assert_eq!(
2240 state.selection().start,
2241 2,
2242 "Cursor should be at position 2 (end of 'Hi')"
2243 );
2244
2245 let text = state.text();
2247 let cursor_pos = state.selection().start;
2248 let text_before_cursor = &text[..cursor_pos.min(text.len())];
2249 assert_eq!(text_before_cursor, "Hi");
2250
2251 let cursor_x = crate::text::measure_text(
2253 &crate::text::AnnotatedString::from(text_before_cursor),
2254 &style,
2255 )
2256 .width;
2257 assert!(
2258 (cursor_x - hi_width).abs() < 0.1,
2259 "Cursor x {} should equal 'Hi' width {}",
2260 cursor_x,
2261 hi_width
2262 );
2263 });
2264 }
2265
2266 #[test]
2268 fn test_focused_node_creates_cursor() {
2269 let _app_context = crate::render_state::app_context_test_scope();
2270 with_test_runtime(|| {
2271 let state = TextFieldState::new("Test");
2272 let element = TextFieldElement::new(state.clone(), TextStyle::default());
2273 let node = element.create();
2274
2275 assert!(!node.is_focused());
2277
2278 *node.refs.is_focused.borrow_mut() = true;
2280 assert!(node.is_focused());
2281
2282 assert_eq!(node.text(), "Test");
2284
2285 assert_eq!(node.selection().start, 4);
2287 });
2288 }
2289}