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}
56
57#[derive(Clone)]
62pub struct TextFieldHandleController {
63 inner: Rc<TextFieldHandleControllerInner>,
64}
65
66impl PartialEq for TextFieldHandleController {
67 fn eq(&self, other: &Self) -> bool {
68 Rc::ptr_eq(&self.inner, &other.inner)
69 }
70}
71
72struct TextFieldHandleControllerInner {
73 metrics: Cell<Option<TextFieldHandleMetrics>>,
74 revision: MutableState<u64>,
75 gesture_claim: RefCell<Option<Rc<Cell<bool>>>>,
79 press_track: Cell<Option<MutableState<Option<PointerPressTrack>>>>,
83}
84
85impl TextFieldHandleController {
86 pub fn new() -> Self {
89 Self {
90 inner: Rc::new(TextFieldHandleControllerInner {
91 metrics: Cell::new(None),
92 revision: mutableStateOf(0u64),
93 gesture_claim: RefCell::new(None),
94 press_track: Cell::new(None),
95 }),
96 }
97 }
98
99 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
102 if self.inner.metrics.get() != Some(metrics) {
103 self.inner.metrics.set(Some(metrics));
104 self.inner
105 .revision
106 .update(|value| *value = value.wrapping_add(1));
107 }
108 }
109
110 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
113 let _ = self.inner.revision.value();
114 self.inner.metrics.get()
115 }
116
117 pub(crate) fn adopt_gesture_claim(&self, claim: &Rc<Cell<bool>>) {
119 let mut slot = self.inner.gesture_claim.borrow_mut();
120 let adopted = slot.as_ref().is_some_and(|held| Rc::ptr_eq(held, claim));
121 if !adopted {
122 *slot = Some(Rc::clone(claim));
123 }
124 }
125
126 pub(crate) fn adopt_press_track(&self, press_track: MutableState<Option<PointerPressTrack>>) {
127 if self.inner.press_track.get() != Some(press_track) {
128 self.inner.press_track.set(Some(press_track));
129 self.inner
130 .revision
131 .update(|value| *value = value.wrapping_add(1));
132 }
133 }
134
135 pub fn press(&self) -> Option<PointerPressTrack> {
137 self.inner.press_track.get().and_then(|state| state.get())
138 }
139
140 pub fn claim_gesture(&self) {
143 if let Some(claim) = self.inner.gesture_claim.borrow().as_ref() {
144 claim.set(true);
145 }
146 }
147
148 pub fn gesture_claimed(&self) -> bool {
150 self.inner
151 .gesture_claim
152 .borrow()
153 .as_ref()
154 .is_some_and(|claim| claim.get())
155 }
156}
157
158impl Default for TextFieldHandleController {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
166
167const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
169
170const DEFAULT_LINE_HEIGHT: f32 = 20.0;
172
173const CURSOR_WIDTH: f32 = 2.0;
175
176pub(crate) fn compute_horizontal_scroll_offset(
187 current_offset: f32,
188 cursor_x: f32,
189 text_width: f32,
190 viewport_width: f32,
191) -> f32 {
192 if viewport_width <= 0.0 {
193 return 0.0;
194 }
195 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
196 let mut offset = current_offset.clamp(0.0, max_offset);
197 let visible_end = offset + viewport_width - CURSOR_WIDTH;
198 if cursor_x > visible_end {
199 offset = cursor_x - viewport_width + CURSOR_WIDTH;
201 } else if cursor_x < offset {
202 offset = cursor_x;
204 }
205 offset.clamp(0.0, max_offset)
206}
207
208pub(crate) fn intersect_rect(
213 rect: cranpose_ui_graphics::Rect,
214 bounds: cranpose_ui_graphics::Rect,
215) -> Option<cranpose_ui_graphics::Rect> {
216 let x0 = rect.x.max(bounds.x);
217 let y0 = rect.y.max(bounds.y);
218 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
219 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
220 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
221 x: x0,
222 y: y0,
223 width: x1 - x0,
224 height: y1 - y0,
225 })
226}
227
228pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
231
232pub(crate) fn caret_visual_line_for_offset(
242 text: &str,
243 style: &TextStyle,
244 node_id: Option<cranpose_core::NodeId>,
245 wrap_width: Option<f32>,
246 offset: usize,
247 affinity: crate::text_selection::LineAffinity,
248) -> (usize, usize) {
249 let offset = offset.min(text.len());
250 match wrap_width {
251 Some(width) if width.is_finite() && width > 0.0 => {
252 let annotated = crate::text::AnnotatedString::from(text);
253 let ranges = crate::text::wrapped_line_ranges(
254 node_id,
255 &annotated,
256 style,
257 crate::text::TextLayoutOptions::default(),
258 Some(width),
259 );
260 crate::text_selection::caret_visual_line(&ranges, offset, affinity)
261 }
262 _ => {
263 let before = &text[..offset];
266 let line_index = before.matches('\n').count();
267 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
268 (line_index, line_start)
269 }
270 }
271}
272
273#[allow(clippy::too_many_arguments)]
284pub(crate) fn range_visual_line_rects(
285 text: &str,
286 style: &TextStyle,
287 node_id: Option<cranpose_core::NodeId>,
288 wrap_width: Option<f32>,
289 padding_left: f32,
290 padding_top: f32,
291 pan: f32,
292 line_height: f32,
293 start: usize,
294 end: usize,
295) -> Vec<cranpose_ui_graphics::Rect> {
296 if start >= end {
297 return Vec::new();
298 }
299 let annotated = crate::text::AnnotatedString::from(text);
300 let line_ranges = crate::text::wrapped_line_ranges(
301 node_id,
302 &annotated,
303 style,
304 crate::text::TextLayoutOptions::default(),
305 wrap_width,
306 );
307 let mut rects = Vec::new();
308 for (line_idx, line_range) in line_ranges.iter().enumerate() {
309 let line_start = line_range.start;
310 let line_end = line_range.end;
311 if end <= line_start || start >= line_end {
312 continue;
313 }
314 let seg_start = start.max(line_start);
315 let seg_end = end.min(line_end);
316 let x0 = crate::text::measure_text(
317 &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
318 style,
319 )
320 .width
321 + padding_left
322 - pan;
323 let x1 = crate::text::measure_text(
324 &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
325 style,
326 )
327 .width
328 + padding_left
329 - pan;
330 let width = x1 - x0;
331 if width > 0.0 {
332 rects.push(cranpose_ui_graphics::Rect {
333 x: x0,
334 y: padding_top + line_idx as f32 * line_height,
335 width,
336 height: line_height,
337 });
338 }
339 }
340 rects
341}
342
343#[derive(Clone)]
349pub(crate) struct TextFieldRefs {
350 pub is_focused: Rc<RefCell<bool>>,
352 pub content_offset: Rc<Cell<f32>>,
354 pub content_y_offset: Rc<Cell<f32>>,
356 pub drag_anchor: Rc<Cell<Option<usize>>>,
358 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
360 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
362 pub click_count: Rc<Cell<u8>>,
364 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
366 pub scroll_offset: Rc<Cell<f32>>,
369 pub direct_manipulation: Rc<Cell<bool>>,
373 pub node_origin: Rc<Cell<Point>>,
377 pub line_height: Rc<Cell<f32>>,
381 pub wrap_width: Rc<Cell<Option<f32>>>,
385 pub press_track: MutableState<Option<PointerPressTrack>>,
388 pub gesture_claimed: Rc<Cell<bool>>,
392}
393
394#[derive(Clone, Copy, Debug, PartialEq)]
397pub struct PointerPressTrack {
398 pub start: Point,
400 pub position: Point,
402}
403
404impl TextFieldRefs {
405 pub fn new() -> Self {
407 Self {
408 is_focused: Rc::new(RefCell::new(false)),
409 content_offset: Rc::new(Cell::new(0.0_f32)),
410 content_y_offset: Rc::new(Cell::new(0.0_f32)),
411 drag_anchor: Rc::new(Cell::new(None::<usize>)),
412 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
413 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
414 click_count: Rc::new(Cell::new(0_u8)),
415 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
416 scroll_offset: Rc::new(Cell::new(0.0_f32)),
417 direct_manipulation: Rc::new(Cell::new(false)),
418 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
419 line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
420 wrap_width: Rc::new(Cell::new(None::<f32>)),
421 press_track: mutableStateOf(None::<PointerPressTrack>),
422 gesture_claimed: Rc::new(Cell::new(false)),
423 }
424 }
425}
426
427use crate::text::TextStyle; pub struct TextFieldModifierNode {
436 state: TextFieldState,
438 refs: TextFieldRefs,
440 style: TextStyle, cursor_brush: Brush,
444 selection_brush: Brush,
446 line_limits: TextFieldLineLimits,
448 cached_text: String,
450 cached_selection: TextRange,
452 node_state: NodeState,
454 measured_size: Rc<Cell<Size>>,
456 measured_line_height: Rc<Cell<f32>>,
458 measured_wrap_width: Rc<Cell<Option<f32>>>,
464 cached_handler: Rc<dyn Fn(PointerEvent)>,
466 cached_pan_resolver: TextPanResolver,
468 handle_controller: Option<TextFieldHandleController>,
472 modal_depth: usize,
476}
477
478impl std::fmt::Debug for TextFieldModifierNode {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 f.debug_struct("TextFieldModifierNode")
481 .field("text", &self.state.text())
482 .field("style", &self.style)
483 .field("is_focused", &*self.refs.is_focused.borrow())
484 .finish()
485 }
486}
487
488use crate::text_field_handler::TextFieldHandler;
490
491impl TextFieldModifierNode {
492 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
494 let value = state.value();
495 let refs = TextFieldRefs::new();
496 let refs_line_height = refs.line_height.clone();
497 let refs_wrap_width = refs.wrap_width.clone();
498 let line_limits = TextFieldLineLimits::default();
499 let cached_handler =
500 Self::create_handler(state, refs.clone(), line_limits, style.clone(), 0);
501 let cached_pan_resolver =
502 Self::create_pan_resolver(state, refs.clone(), line_limits, style.clone());
503
504 Self {
505 state,
506 refs,
507 style,
508 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
509 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
510 line_limits,
511 cached_text: value.text,
512 cached_selection: value.selection,
513 node_state: NodeState::new(),
514 measured_size: Rc::new(Cell::new(Size {
515 width: 0.0,
516 height: 0.0,
517 })),
518 measured_line_height: refs_line_height,
522 measured_wrap_width: refs_wrap_width,
523 cached_handler,
524 cached_pan_resolver,
525 handle_controller: None,
526 modal_depth: 0,
527 }
528 }
529
530 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
532 self.line_limits = line_limits;
533 self.rebuild_cached_closures();
534 self
535 }
536
537 fn rebuild_cached_closures(&mut self) {
546 self.cached_handler = Self::create_handler(
547 self.state,
548 self.refs.clone(),
549 self.line_limits,
550 self.style.clone(),
551 self.modal_depth,
552 );
553 self.cached_pan_resolver = Self::create_pan_resolver(
554 self.state,
555 self.refs.clone(),
556 self.line_limits,
557 self.style.clone(),
558 );
559 }
560
561 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
563 self.handle_controller = Some(controller);
564 self
565 }
566
567 fn create_pan_resolver(
575 state: TextFieldState,
576 refs: TextFieldRefs,
577 line_limits: TextFieldLineLimits,
578 style: TextStyle,
579 ) -> TextPanResolver {
580 Rc::new(move |viewport_width: f32| {
581 if !line_limits.is_single_line() {
582 refs.scroll_offset.set(0.0);
584 return 0.0;
585 }
586 let text = state.text();
587 let pos = state.selection().start.min(text.len());
588 let text_width = crate::text::measure_text(
589 &crate::text::AnnotatedString::from(text.as_str()),
590 &style,
591 )
592 .width;
593 let cursor_x = crate::text::measure_text(
594 &crate::text::AnnotatedString::from(&text[..pos]),
595 &style,
596 )
597 .width;
598 let offset = compute_horizontal_scroll_offset(
599 refs.scroll_offset.get(),
600 cursor_x,
601 text_width,
602 viewport_width,
603 );
604 refs.scroll_offset.set(offset);
605 offset
606 })
607 }
608
609 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
614 self.line_limits
615 .is_single_line()
616 .then(|| self.cached_pan_resolver.clone())
617 }
618
619 pub fn scroll_offset(&self) -> f32 {
621 self.refs.scroll_offset.get()
622 }
623
624 pub fn line_limits(&self) -> TextFieldLineLimits {
626 self.line_limits
627 }
628
629 fn create_handler(
631 state: TextFieldState,
632 refs: TextFieldRefs,
633 line_limits: TextFieldLineLimits,
634 style: TextStyle, modal_depth: usize,
636 ) -> Rc<dyn Fn(PointerEvent)> {
637 use crate::text_selection::{
640 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
641 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
642 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
643 };
644 use crate::word_boundaries::find_word_boundaries;
645
646 Rc::new(move |event: PointerEvent| {
647 refs.node_origin.set(Point {
654 x: event.global_position.x - event.position.x,
655 y: event.global_position.y - event.position.y,
656 });
657
658 let click_x =
662 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
663 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
664
665 match event.kind {
666 PointerEventKind::Down => {
667 refs.direct_manipulation.set(true);
671 refs.press_track.set(Some(PointerPressTrack {
672 start: event.global_position,
673 position: event.global_position,
674 }));
675 refs.gesture_claimed.set(false);
676
677 let handler = TextFieldHandler::new(
682 state,
683 refs.node_id.get(),
684 line_limits,
685 crate::text_field_handler::CaretGeometryRefs {
686 node_origin: refs.node_origin.clone(),
687 content_offset: refs.content_offset.clone(),
688 content_y_offset: refs.content_y_offset.clone(),
689 scroll_offset: refs.scroll_offset.clone(),
690 style: style.clone(),
691 },
692 );
693 crate::text_field_focus::request_focus(
694 refs.is_focused.clone(),
695 handler,
696 modal_depth,
697 );
698
699 let now = web_time::Instant::now();
700 let text = state.text();
701 let pos = crate::text::offset_for_position_wrapped(
702 &text,
703 &style,
704 refs.node_id.get(),
705 refs.wrap_width.get(),
706 refs.line_height.get(),
707 click_x,
708 click_y,
709 );
710
711 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
716 let count = refs.click_count.get();
717 (count > 0).then_some((count, px, py))
718 });
719 let elapsed_ms = refs
720 .last_click_time
721 .get()
722 .map(|last| now.duration_since(last).as_millis())
723 .unwrap_or(u128::MAX);
724 let tap_count = classify_tap_count(
725 previous,
726 elapsed_ms,
727 event.position.x,
728 event.position.y,
729 MULTI_TAP_TIMEOUT_MS,
730 MULTI_TAP_SLOP_PX,
731 );
732
733 let selection = state.selection();
741 let tap_in_selection =
742 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
743 let repeat_in_place = refs
746 .last_click_pos
747 .get()
748 .map(|(px, py)| {
749 let dx = event.position.x - px;
750 let dy = event.position.y - py;
751 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
752 })
753 .unwrap_or(false);
754 let effective_count = resolve_selection_tap_count(
755 tap_count,
756 refs.click_count.get(),
757 tap_in_selection,
758 repeat_in_place,
759 );
760
761 match tap_selection_granularity(effective_count) {
762 SelectionGranularity::Paragraph => {
763 let (start, end) = find_paragraph_boundaries(&text, pos);
765 state.edit(|buffer| {
766 buffer.select(TextRange::new(start, end));
767 });
768 refs.drag_anchor.set(Some(start));
769 }
770 SelectionGranularity::Line => {
771 let (line_start, line_end) = find_line_boundaries(&text, pos);
773 state.edit(|buffer| {
774 buffer.select(TextRange::new(line_start, line_end));
775 });
776 refs.drag_anchor.set(Some(line_start));
777 }
778 SelectionGranularity::Word => {
779 let (word_start, word_end) = find_word_boundaries(&text, pos);
782 state.edit(|buffer| {
783 buffer.select(TextRange::new(word_start, word_end));
784 });
785 refs.drag_anchor.set(Some(word_start));
786 }
787 SelectionGranularity::Caret => {
788 refs.drag_anchor.set(Some(pos));
790 state.edit(|buffer| {
791 buffer.place_cursor_before_char(pos);
792 });
793 }
794 }
795
796 refs.click_count.set(effective_count);
797 refs.last_click_time.set(Some(now));
798 refs.last_click_pos
799 .set(Some((event.position.x, event.position.y)));
800 event.consume();
801 }
802 PointerEventKind::Move => {
803 if let Some(mut track) = refs.press_track.get() {
805 track.position = event.global_position;
806 refs.press_track.set(Some(track));
807 crate::request_render_invalidation();
808 }
809 if refs.gesture_claimed.get() {
812 event.consume();
813 return;
814 }
815 if let Some(anchor) = refs.drag_anchor.get() {
817 if *refs.is_focused.borrow() {
818 let text = state.text();
819 let current_pos = crate::text::offset_for_position_wrapped(
820 &text,
821 &style,
822 refs.node_id.get(),
823 refs.wrap_width.get(),
824 refs.line_height.get(),
825 click_x,
826 click_y,
827 );
828
829 state.set_selection(TextRange::new(anchor, current_pos));
831
832 crate::request_render_invalidation();
834
835 event.consume();
836 }
837 }
838 }
839 PointerEventKind::Up => {
840 refs.drag_anchor.set(None);
842 refs.press_track.set(None);
843 refs.gesture_claimed.set(false);
844 crate::request_render_invalidation();
850 }
851 PointerEventKind::Cancel => {
852 refs.press_track.set(None);
853 refs.gesture_claimed.set(false);
854 crate::request_render_invalidation();
855 }
856 _ => {}
857 }
858 })
859 }
860
861 pub fn with_cursor_color(mut self, color: Color) -> Self {
866 self.cursor_brush = Brush::solid(color);
867 self.selection_brush = Brush::solid(
868 color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
869 );
870 self
871 }
872
873 pub fn set_focused(&mut self, focused: bool) {
875 let current = *self.refs.is_focused.borrow();
876 if current != focused {
877 *self.refs.is_focused.borrow_mut() = focused;
878 if !focused {
879 self.refs.direct_manipulation.set(false);
880 self.refs.press_track.set(None);
881 self.refs.gesture_claimed.set(false);
882 }
883 }
884 }
885
886 pub fn is_focused(&self) -> bool {
888 *self.refs.is_focused.borrow()
889 }
890
891 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
904 self.refs.node_origin.clone()
905 }
906
907 pub fn text(&self) -> String {
909 self.state.text()
910 }
911
912 pub fn style(&self) -> &TextStyle {
913 &self.style
914 }
915
916 pub fn selection(&self) -> TextRange {
918 self.state.selection()
919 }
920
921 pub fn cursor_brush(&self) -> Brush {
923 self.cursor_brush.clone()
924 }
925
926 pub fn selection_brush(&self) -> Brush {
928 self.selection_brush.clone()
929 }
930
931 pub fn insert_text(&mut self, text: &str) {
933 self.state.edit(|buffer| {
934 buffer.insert(text);
935 });
936 }
937
938 pub fn copy_selection(&self) -> Option<String> {
941 self.state.copy_selection()
942 }
943
944 pub fn cut_selection(&mut self) -> Option<String> {
947 let text = self.copy_selection();
948 if text.is_some() {
949 self.state.edit(|buffer| {
950 buffer.delete(buffer.selection());
951 });
952 }
953 text
954 }
955
956 pub fn set_content_offset(&self, offset: f32) {
959 self.refs.content_offset.set(offset);
960 }
961
962 pub fn set_content_y_offset(&self, offset: f32) {
965 self.refs.content_y_offset.set(offset);
966 }
967
968 fn wrap_width(&self, available_width: f32) -> Option<f32> {
975 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
976 .then_some(available_width)
977 }
978
979 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
985 let text = self.state.text();
986 let node_id = self.refs.node_id.get();
987 let annotated = crate::text::AnnotatedString::from(text.as_str());
988 let metrics = match wrap_width {
989 Some(max_width) => crate::text::measure_text_with_options_for_node(
990 node_id,
991 &annotated,
992 &self.style,
993 crate::text::TextLayoutOptions::default(),
994 Some(max_width),
995 ),
996 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
997 };
998 self.measured_line_height.set(metrics.line_height);
999 Size {
1000 width: metrics.width,
1001 height: metrics.height,
1002 }
1003 }
1004
1005 fn update_cached_state(&mut self) -> bool {
1007 let value = self.state.value();
1008 let text_changed = value.text != self.cached_text;
1009 let selection_changed = value.selection != self.cached_selection;
1010
1011 if text_changed {
1012 self.cached_text = value.text;
1013 }
1014 if selection_changed {
1015 self.cached_selection = value.selection;
1016 }
1017
1018 text_changed || selection_changed
1019 }
1020
1021 }
1025
1026impl DelegatableNode for TextFieldModifierNode {
1027 fn node_state(&self) -> &NodeState {
1028 &self.node_state
1029 }
1030}
1031
1032impl ModifierNode for TextFieldModifierNode {
1033 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
1034 self.refs.node_id.set(context.node_id());
1036
1037 context.invalidate(InvalidationKind::Layout);
1038 context.invalidate(InvalidationKind::Draw);
1039 context.invalidate(InvalidationKind::Semantics);
1040 }
1041
1042 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
1043 Some(self)
1044 }
1045
1046 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
1047 Some(self)
1048 }
1049
1050 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
1051 Some(self)
1052 }
1053
1054 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
1055 Some(self)
1056 }
1057
1058 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
1059 Some(self)
1060 }
1061
1062 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
1063 Some(self)
1064 }
1065
1066 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
1067 Some(self)
1068 }
1069
1070 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
1071 Some(self)
1072 }
1073}
1074
1075impl LayoutModifierNode for TextFieldModifierNode {
1076 fn measure(
1077 &self,
1078 _context: &mut dyn ModifierNodeContext,
1079 _measurable: &dyn Measurable,
1080 constraints: Constraints,
1081 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
1082 let wrap_width = self.wrap_width(constraints.max_width);
1086 self.measured_wrap_width.set(wrap_width);
1089 let text_size = self.measure_text_content(wrap_width);
1090
1091 let min_height = if text_size.height < 1.0 {
1093 DEFAULT_LINE_HEIGHT
1094 } else {
1095 text_size.height
1096 };
1097
1098 let width = text_size
1100 .width
1101 .max(constraints.min_width)
1102 .min(constraints.max_width);
1103 let height = min_height
1104 .max(constraints.min_height)
1105 .min(constraints.max_height);
1106
1107 let size = Size { width, height };
1108 self.measured_size.set(size);
1109
1110 let _ = (self.cached_pan_resolver)(size.width);
1113
1114 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
1115 }
1116
1117 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1118 self.measure_text_content(None).width
1119 }
1120
1121 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1122 self.measure_text_content(None).width
1123 }
1124
1125 fn min_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 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1132 self.measure_text_content(self.wrap_width(width))
1133 .height
1134 .max(DEFAULT_LINE_HEIGHT)
1135 }
1136}
1137
1138fn content_viewport(
1142 measured: cranpose_ui_graphics::Size,
1143 size: cranpose_foundation::Size,
1144 padding_left: f32,
1145 padding_top: f32,
1146) -> (f32, f32) {
1147 let width = if measured.width > 0.0 {
1148 measured.width
1149 } else {
1150 (size.width - padding_left).max(0.0)
1151 };
1152 let height = if measured.height > 0.0 {
1153 measured.height
1154 } else {
1155 (size.height - padding_top).max(0.0)
1156 };
1157 (width, height)
1158}
1159
1160impl DrawModifierNode for TextFieldModifierNode {
1161 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
1162 }
1166
1167 fn create_draw_closure(
1168 &self,
1169 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1170 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1171
1172 let is_focused = self.refs.is_focused.clone();
1174 let state = self.state;
1175 let content_offset = self.refs.content_offset.clone();
1176 let content_y_offset = self.refs.content_y_offset.clone();
1177 let cursor_brush = self.cursor_brush.clone();
1178 let style = self.style.clone();
1179 let cached_line_height = self.measured_line_height.clone();
1180 let measured_size = self.measured_size.clone();
1181 let measured_wrap_width = self.measured_wrap_width.clone();
1182 let node_id = self.refs.node_id.clone();
1183 let pan_resolver = self.cached_pan_resolver.clone();
1184 let handle_controller = self.handle_controller.clone();
1185 let node_origin = self.refs.node_origin.clone();
1186 let direct_manipulation = self.refs.direct_manipulation.clone();
1187 let press_track = self.refs.press_track;
1188 let gesture_claimed = self.refs.gesture_claimed.clone();
1189
1190 Some(Rc::new(move |scope| {
1191 let size = scope.size();
1192 if !*is_focused.borrow() {
1194 if let Some(controller) = &handle_controller {
1197 controller.publish(TextFieldHandleMetrics {
1198 focused: false,
1199 direct_manipulation: false,
1200 node_origin: node_origin.get(),
1201 padding_left: 0.0,
1202 padding_top: 0.0,
1203 scroll_offset: 0.0,
1204 line_height: cached_line_height.get(),
1205 glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
1206 wrap_width: measured_wrap_width.get(),
1207 });
1208 }
1209 return;
1210 }
1211
1212 let mut primitives = Vec::new();
1213
1214 let text = state.text();
1215 let selection = state.selection();
1216 let padding_left = content_offset.get();
1217 let padding_top = content_y_offset.get();
1218 let line_height = cached_line_height.get();
1221
1222 let (viewport_width, viewport_height) =
1223 content_viewport(measured_size.get(), size, padding_left, padding_top);
1224 let pan = pan_resolver(viewport_width);
1226
1227 if let Some(controller) = &handle_controller {
1230 controller.adopt_gesture_claim(&gesture_claimed);
1231 controller.adopt_press_track(press_track);
1232 controller.publish(TextFieldHandleMetrics {
1233 focused: true,
1234 direct_manipulation: direct_manipulation.get(),
1235 node_origin: node_origin.get(),
1236 padding_left,
1237 padding_top,
1238 scroll_offset: pan,
1239 line_height,
1240 glyph_box: crate::text::glyph_line_box(&style, line_height),
1241 wrap_width: measured_wrap_width.get(),
1242 });
1243 }
1244 let clip_bounds = cranpose_ui_graphics::Rect {
1248 x: padding_left,
1249 y: padding_top,
1250 width: viewport_width,
1251 height: viewport_height,
1252 };
1253
1254 if let Some(comp_range) = state.composition() {
1261 let comp_start = comp_range.min();
1262 let comp_end = comp_range.max();
1263
1264 if comp_start < comp_end && comp_end <= text.len() {
1265 let underline_brush = cranpose_ui_graphics::Brush::solid(
1267 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1268 );
1269 let underline_height: f32 = 2.0;
1270
1271 for line_rect in range_visual_line_rects(
1274 &text,
1275 &style,
1276 node_id.get(),
1277 measured_wrap_width.get(),
1278 padding_left,
1279 padding_top,
1280 pan,
1281 line_height,
1282 comp_start,
1283 comp_end,
1284 ) {
1285 let underline_rect = cranpose_ui_graphics::Rect {
1286 x: line_rect.x,
1287 y: line_rect.y + line_height - underline_height,
1288 width: line_rect.width,
1289 height: underline_height,
1290 };
1291 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1292 primitives.push(DrawPrimitive::Rect {
1293 rect: clipped,
1294 brush: underline_brush.clone(),
1295 stroke: None,
1296 });
1297 }
1298 }
1299 }
1300 }
1301
1302 if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1308 let pos = selection.start.min(text.len());
1309 let (line_index, line_start) = caret_visual_line_for_offset(
1318 &text,
1319 &style,
1320 node_id.get(),
1321 measured_wrap_width.get(),
1322 pos,
1323 crate::text_selection::LineAffinity::Upstream,
1324 );
1325 let cursor_x = crate::text::measure_text(
1326 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1327 &style,
1328 )
1329 .width
1330 + padding_left
1331 - pan;
1332 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1335 let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1336
1337 let cursor_rect = cranpose_ui_graphics::Rect {
1338 x: cursor_x,
1339 y: cursor_y,
1340 width: CURSOR_WIDTH,
1341 height: box_h,
1342 };
1343
1344 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1345 primitives.push(DrawPrimitive::Rect {
1346 rect: clipped,
1347 brush: cursor_brush.clone(),
1348 stroke: None,
1349 });
1350 }
1351 }
1352
1353 scope.push_recorded(primitives);
1354 }))
1355 }
1356
1357 fn create_behind_draw_closure(
1358 &self,
1359 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1360 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1361
1362 let is_focused = self.refs.is_focused.clone();
1363 let state = self.state;
1364 let content_offset = self.refs.content_offset.clone();
1365 let content_y_offset = self.refs.content_y_offset.clone();
1366 let selection_brush = self.selection_brush.clone();
1367 let style = self.style.clone();
1368 let cached_line_height = self.measured_line_height.clone();
1369 let measured_size = self.measured_size.clone();
1370 let measured_wrap_width = self.measured_wrap_width.clone();
1371 let node_id = self.refs.node_id.clone();
1372 let pan_resolver = self.cached_pan_resolver.clone();
1373
1374 Some(Rc::new(move |scope| {
1375 let size = scope.size();
1376 if !*is_focused.borrow() {
1377 return;
1378 }
1379 let selection = state.selection();
1380 if selection.collapsed() {
1381 return;
1382 }
1383 let text = state.text();
1384 let padding_left = content_offset.get();
1385 let padding_top = content_y_offset.get();
1386 let line_height = cached_line_height.get();
1387 let (viewport_width, viewport_height) =
1388 content_viewport(measured_size.get(), size, padding_left, padding_top);
1389 let pan = pan_resolver(viewport_width);
1390 let clip_bounds = cranpose_ui_graphics::Rect {
1391 x: padding_left,
1392 y: padding_top,
1393 width: viewport_width,
1394 height: viewport_height,
1395 };
1396
1397 let mut primitives = Vec::new();
1401 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1405 for sel_rect in range_visual_line_rects(
1406 &text,
1407 &style,
1408 node_id.get(),
1409 measured_wrap_width.get(),
1410 padding_left,
1411 padding_top,
1412 pan,
1413 line_height,
1414 selection.min(),
1415 selection.max(),
1416 ) {
1417 let sel_rect = cranpose_ui_graphics::Rect {
1418 y: sel_rect.y + box_off,
1419 height: box_h,
1420 ..sel_rect
1421 };
1422 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1423 primitives.push(DrawPrimitive::Rect {
1424 rect: clipped,
1425 brush: selection_brush.clone(),
1426 stroke: None,
1427 });
1428 }
1429 }
1430 scope.push_recorded(primitives);
1431 }))
1432 }
1433}
1434
1435impl SemanticsNode for TextFieldModifierNode {
1436 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1437 let text = self.state.text();
1438 config.content_description = Some(text);
1439 config.is_editable_text = true;
1440 config.text_selection = Some(self.state.selection());
1441 }
1442}
1443
1444impl PointerInputNode for TextFieldModifierNode {
1445 fn on_pointer_event(
1446 &mut self,
1447 _context: &mut dyn ModifierNodeContext,
1448 _event: &PointerEvent,
1449 ) -> bool {
1450 false
1461 }
1462
1463 fn hit_test(&self, x: f32, y: f32) -> bool {
1464 let size = self.measured_size.get();
1466 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1467 }
1468
1469 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1470 Some(self.cached_handler.clone())
1472 }
1473}
1474
1475#[derive(Clone)]
1486pub struct TextFieldElement {
1487 state: TextFieldState,
1489 style: TextStyle,
1491 cursor_color: Color,
1493 line_limits: TextFieldLineLimits,
1495 handle_controller: Option<TextFieldHandleController>,
1498 modal_depth: usize,
1500}
1501
1502impl TextFieldElement {
1503 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1505 Self {
1506 state,
1507 style,
1508 cursor_color: DEFAULT_CURSOR_COLOR,
1509 line_limits: TextFieldLineLimits::default(),
1510 handle_controller: None,
1511 modal_depth: 0,
1512 }
1513 }
1514
1515 pub fn with_cursor_color(mut self, color: Color) -> Self {
1517 self.cursor_color = color;
1518 self
1519 }
1520
1521 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1523 self.line_limits = line_limits;
1524 self
1525 }
1526
1527 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1529 self.handle_controller = Some(controller);
1530 self
1531 }
1532
1533 pub fn with_modal_depth(mut self, depth: usize) -> Self {
1536 self.modal_depth = depth;
1537 self
1538 }
1539}
1540
1541impl std::fmt::Debug for TextFieldElement {
1542 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1543 f.debug_struct("TextFieldElement")
1544 .field("text", &self.state.text())
1545 .field("style", &self.style)
1546 .field("cursor_color", &self.cursor_color)
1547 .finish()
1548 }
1549}
1550
1551impl Hash for TextFieldElement {
1552 fn hash<H: Hasher>(&self, state: &mut H) {
1553 self.state.id().hash(state);
1556 self.cursor_color.0.to_bits().hash(state);
1558 self.cursor_color.1.to_bits().hash(state);
1559 self.cursor_color.2.to_bits().hash(state);
1560 self.cursor_color.3.to_bits().hash(state);
1561 self.style.render_hash().hash(state);
1562 self.line_limits.hash(state);
1563 self.modal_depth.hash(state);
1564 }
1565}
1566
1567impl PartialEq for TextFieldElement {
1568 fn eq(&self, other: &Self) -> bool {
1569 self.state == other.state
1573 && self.style == other.style
1574 && self.cursor_color == other.cursor_color
1575 && self.line_limits == other.line_limits
1576 && self.modal_depth == other.modal_depth
1577 }
1578}
1579
1580impl Eq for TextFieldElement {}
1581
1582impl ModifierNodeElement for TextFieldElement {
1583 type Node = TextFieldModifierNode;
1584
1585 fn create(&self) -> Self::Node {
1586 let mut node = TextFieldModifierNode::new(self.state, self.style.clone())
1587 .with_cursor_color(self.cursor_color)
1588 .with_line_limits(self.line_limits);
1589 node.modal_depth = self.modal_depth;
1590 if let Some(controller) = self.handle_controller.clone() {
1591 node = node.with_handle_controller(controller);
1592 }
1593 node.rebuild_cached_closures();
1594 node
1595 }
1596
1597 fn update(&self, node: &mut Self::Node) {
1598 node.state = self.state;
1600 node.style = self.style.clone();
1601 node.cursor_brush = Brush::solid(self.cursor_color);
1602 node.line_limits = self.line_limits;
1603 node.handle_controller = self.handle_controller.clone();
1604 node.modal_depth = self.modal_depth;
1605 node.rebuild_cached_closures();
1606
1607 if node.update_cached_state() {
1609 }
1612 }
1613
1614 fn capabilities(&self) -> NodeCapabilities {
1615 NodeCapabilities::LAYOUT
1616 | NodeCapabilities::DRAW
1617 | NodeCapabilities::SEMANTICS
1618 | NodeCapabilities::POINTER_INPUT
1619 }
1620
1621 fn always_update(&self) -> bool {
1622 true
1624 }
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629 use super::*;
1630 use crate::text::TextStyle;
1631 use cranpose_core::{DefaultScheduler, Runtime};
1632 use std::sync::Arc;
1633
1634 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1636 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1637 f()
1638 }
1639
1640 #[test]
1641 fn text_field_node_creation() {
1642 let _app_context = crate::render_state::app_context_test_scope();
1643 with_test_runtime(|| {
1644 let state = TextFieldState::new("Hello");
1645 let node = TextFieldModifierNode::new(state, TextStyle::default());
1646 assert_eq!(node.text(), "Hello");
1647 assert!(!node.is_focused());
1648 });
1649 }
1650
1651 #[test]
1656 fn selection_rects_follow_wrapped_visual_lines() {
1657 let _app_context = crate::render_state::app_context_test_scope();
1658 let text = "aaaaa\nbb";
1661 let style = TextStyle::default();
1662 let line_height = 10.0_f32;
1663
1664 let rects = range_visual_line_rects(
1667 text,
1668 &style,
1669 None,
1670 Some(30.0),
1671 0.0,
1672 0.0,
1673 0.0,
1674 line_height,
1675 6,
1676 8,
1677 );
1678 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1679 assert_eq!(
1680 rects[0].y,
1681 2.0 * line_height,
1682 "highlight must land on visual line 2, not logical line 1"
1683 );
1684 assert!(rects[0].width > 0.0);
1685
1686 let spanning = range_visual_line_rects(
1689 text,
1690 &style,
1691 None,
1692 Some(30.0),
1693 0.0,
1694 0.0,
1695 0.0,
1696 line_height,
1697 0,
1698 5,
1699 );
1700 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1701 assert_eq!(spanning[0].y, 0.0);
1702 assert_eq!(spanning[1].y, line_height);
1703 }
1704
1705 #[test]
1710 fn tap_resolves_offset_on_wrapped_visual_line() {
1711 let _app_context = crate::render_state::app_context_test_scope();
1712 let text = "aaaaa\nbb";
1715 let style = TextStyle::default();
1716 let line_height = 10.0_f32;
1717
1718 let off = crate::text::offset_for_position_wrapped(
1721 text,
1722 &style,
1723 None,
1724 Some(30.0),
1725 line_height,
1726 8.0,
1727 22.0,
1728 );
1729 assert!(
1730 (6..=8).contains(&off),
1731 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1732 );
1733
1734 let off1 = crate::text::offset_for_position_wrapped(
1737 text,
1738 &style,
1739 None,
1740 Some(30.0),
1741 line_height,
1742 4.0,
1743 12.0,
1744 );
1745 assert!(
1746 (3..=5).contains(&off1),
1747 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1748 );
1749
1750 let off2 = crate::text::offset_for_position_wrapped(
1752 "hello",
1753 &style,
1754 None,
1755 None,
1756 line_height,
1757 0.0,
1758 0.0,
1759 );
1760 assert_eq!(off2, 0);
1761 }
1762
1763 #[test]
1764 fn text_field_node_focus() {
1765 let _app_context = crate::render_state::app_context_test_scope();
1766 with_test_runtime(|| {
1767 let state = TextFieldState::new("Test");
1768 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1769 assert!(!node.is_focused());
1770
1771 node.set_focused(true);
1772 assert!(node.is_focused());
1773
1774 node.set_focused(false);
1775 assert!(!node.is_focused());
1776 });
1777 }
1778
1779 #[test]
1780 fn text_field_element_creates_node() {
1781 let _app_context = crate::render_state::app_context_test_scope();
1782 with_test_runtime(|| {
1783 let state = TextFieldState::new("Hello World");
1784 let element = TextFieldElement::new(state, TextStyle::default());
1785
1786 let node = element.create();
1787 assert_eq!(node.text(), "Hello World");
1788 });
1789 }
1790
1791 #[test]
1796 fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1797 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1798 use cranpose_ui_graphics::Point;
1799
1800 let _app_context = crate::render_state::app_context_test_scope();
1801 with_test_runtime(|| {
1802 let state = TextFieldState::new("hello world");
1803 let controller = TextFieldHandleController::new();
1804 let mut node = TextFieldModifierNode::new(state, TextStyle::default())
1805 .with_handle_controller(controller.clone());
1806 node.measured_size.set(Size {
1808 width: 120.0,
1809 height: 20.0,
1810 });
1811
1812 let handler = node
1813 .pointer_input_handler()
1814 .expect("field exposes a pointer handler");
1815 let draw = node
1816 .create_draw_closure()
1817 .expect("field exposes a draw closure");
1818 let at = Point { x: 12.0, y: 8.0 };
1819 let size = Size {
1820 width: 120.0,
1821 height: 20.0,
1822 };
1823 let run_draw = || {
1826 let mut scope = crate::draw::command_draw_scope(size);
1827 draw(&mut scope);
1828 };
1829
1830 node.set_focused(true);
1831 run_draw();
1832 let keyboard_metrics = controller
1833 .metrics()
1834 .expect("focused field publishes handle metrics");
1835 assert!(!keyboard_metrics.direct_manipulation);
1836
1837 handler(
1838 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1839 );
1840 run_draw();
1841 let metrics = controller
1842 .metrics()
1843 .expect("focused field publishes handle metrics");
1844 assert!(metrics.focused, "a tap focuses the field");
1845 assert!(
1846 metrics.direct_manipulation,
1847 "a touch tap must expose direct-manipulation handles"
1848 );
1849 assert!(
1850 controller.press().is_some(),
1851 "touch must publish the live press"
1852 );
1853
1854 handler(
1855 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1856 );
1857 run_draw();
1858 let metrics = controller
1859 .metrics()
1860 .expect("focused field publishes handle metrics");
1861 assert!(
1862 metrics.direct_manipulation,
1863 "a mouse tap must expose the same direct-manipulation handles"
1864 );
1865 assert!(
1866 controller.press().is_some(),
1867 "mouse must publish the live press"
1868 );
1869
1870 handler(
1871 PointerEvent::new(PointerEventKind::Down, at, at)
1872 .with_source(PointerSource::Stylus),
1873 );
1874 run_draw();
1875 let metrics = controller
1876 .metrics()
1877 .expect("focused field publishes handle metrics");
1878 assert!(
1879 metrics.direct_manipulation,
1880 "a stylus contact must expose the same direct-manipulation handles"
1881 );
1882 assert!(
1883 controller.press().is_some(),
1884 "stylus must publish the live press"
1885 );
1886
1887 crate::text_field_focus::clear_focus();
1888 });
1889 }
1890
1891 #[test]
1899 fn double_tap_selects_the_word_under_the_finger() {
1900 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1901 use cranpose_ui_graphics::Point;
1902
1903 let _app_context = crate::render_state::app_context_test_scope();
1904 with_test_runtime(|| {
1905 let state = TextFieldState::new("hello world");
1906 let node = TextFieldModifierNode::new(state, TextStyle::default());
1907 node.measured_size.set(Size {
1908 width: 200.0,
1909 height: 20.0,
1910 });
1911 let handler = node
1912 .pointer_input_handler()
1913 .expect("field exposes a pointer handler");
1914
1915 let at = Point { x: 2.0, y: 8.0 };
1918 handler(
1919 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1920 );
1921 handler(
1922 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1923 );
1924
1925 let selection = state.selection();
1926 assert!(
1927 !selection.collapsed(),
1928 "a double tap must produce a (word) selection, got {selection:?}"
1929 );
1930 let selected = &state.text()[selection.min()..selection.max()];
1931 assert_eq!(
1932 selected, "hello",
1933 "double tap should select the whole word under the finger"
1934 );
1935
1936 crate::text_field_focus::clear_focus();
1937 });
1938 }
1939
1940 #[test]
1944 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1945 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1946 use cranpose_ui_graphics::Point;
1947
1948 let _app_context = crate::render_state::app_context_test_scope();
1949 with_test_runtime(|| {
1950 let text = "alpha beta\ngamma delta\n\nsecond para";
1953 let state = TextFieldState::new(text);
1954 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
1955 TextFieldLineLimits::MultiLine {
1956 min_lines: 1,
1957 max_lines: usize::MAX,
1958 },
1959 );
1960 node.measured_size.set(Size {
1961 width: 400.0,
1962 height: 80.0,
1963 });
1964 let handler = node
1965 .pointer_input_handler()
1966 .expect("field exposes a pointer handler");
1967
1968 let at = Point { x: 2.0, y: 4.0 };
1970 let tap = || {
1971 handler(
1972 PointerEvent::new(PointerEventKind::Down, at, at)
1973 .with_source(PointerSource::Touch),
1974 );
1975 };
1976 let selected = |state: &TextFieldState| {
1977 let s = state.selection();
1978 state.text()[s.min()..s.max()].to_string()
1979 };
1980
1981 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1983 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1985 tap(); assert_eq!(
1987 selected(&state),
1988 "alpha beta",
1989 "triple tap selects the line"
1990 );
1991 tap(); assert_eq!(
1993 selected(&state),
1994 "alpha beta\ngamma delta",
1995 "fourth tap grows to the paragraph"
1996 );
1997 tap(); assert_eq!(
1999 selected(&state),
2000 "alpha",
2001 "fifth tap cycles back to the word"
2002 );
2003
2004 crate::text_field_focus::clear_focus();
2005 });
2006 }
2007
2008 #[test]
2012 fn single_tap_inside_selection_selects_the_word() {
2013 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2014 use cranpose_ui_graphics::Point;
2015
2016 let _app_context = crate::render_state::app_context_test_scope();
2017 with_test_runtime(|| {
2018 let state = TextFieldState::new("hello world");
2019 let node = TextFieldModifierNode::new(state, TextStyle::default());
2020 node.measured_size.set(Size {
2021 width: 200.0,
2022 height: 20.0,
2023 });
2024 let handler = node
2025 .pointer_input_handler()
2026 .expect("field exposes a pointer handler");
2027
2028 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
2030 assert!(!state.selection().collapsed());
2031
2032 let at = Point { x: 2.0, y: 8.0 };
2035 handler(
2036 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
2037 );
2038
2039 let selection = state.selection();
2040 assert!(
2041 !selection.collapsed(),
2042 "a tap inside a selection must not collapse it, got {selection:?}"
2043 );
2044 assert_eq!(
2045 &state.text()[selection.min()..selection.max()],
2046 "hello",
2047 "a tap inside a selection re-selects the word under the finger"
2048 );
2049
2050 crate::text_field_focus::clear_focus();
2051 });
2052 }
2053
2054 #[test]
2062 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
2063 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2064 use cranpose_ui_graphics::Point;
2065
2066 let _app_context = crate::render_state::app_context_test_scope();
2067 with_test_runtime(|| {
2068 let text = "alpha beta\ngamma delta\n\nsecond para";
2069 let state = TextFieldState::new(text);
2070 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
2071 TextFieldLineLimits::MultiLine {
2072 min_lines: 1,
2073 max_lines: usize::MAX,
2074 },
2075 );
2076 node.measured_size.set(Size {
2077 width: 400.0,
2078 height: 80.0,
2079 });
2080 let handler = node
2081 .pointer_input_handler()
2082 .expect("field exposes a pointer handler");
2083
2084 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
2086
2087 let at = Point { x: 2.0, y: 4.0 };
2088 let selected = |state: &TextFieldState| {
2089 let s = state.selection();
2090 state.text()[s.min()..s.max()].to_string()
2091 };
2092 let slow_tap = || {
2095 node.refs.last_click_time.set(None);
2096 handler(
2097 PointerEvent::new(PointerEventKind::Down, at, at)
2098 .with_source(PointerSource::Touch),
2099 );
2100 };
2101
2102 slow_tap(); assert_eq!(
2104 selected(&state),
2105 "alpha",
2106 "tap inside selection grabs the word"
2107 );
2108 slow_tap(); assert_eq!(
2110 selected(&state),
2111 "alpha beta",
2112 "same-spot tap grows to the line even after the timeout"
2113 );
2114 slow_tap(); assert_eq!(
2116 selected(&state),
2117 "alpha beta\ngamma delta",
2118 "same-spot tap grows to the paragraph"
2119 );
2120 slow_tap(); assert_eq!(
2122 selected(&state),
2123 "alpha",
2124 "same-spot tap cycles back to the word"
2125 );
2126
2127 crate::text_field_focus::clear_focus();
2128 });
2129 }
2130
2131 #[test]
2132 fn text_field_element_equality() {
2133 let _app_context = crate::render_state::app_context_test_scope();
2134 with_test_runtime(|| {
2135 let state1 = TextFieldState::new("Hello");
2136 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1, TextStyle::default());
2139 let elem2 = TextFieldElement::new(state1, TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
2145 assert_ne!(elem1, elem3, "Different states should not be equal");
2146 });
2147 }
2148
2149 #[test]
2150 fn text_field_element_update_refreshes_existing_node_style() {
2151 let _app_context = crate::render_state::app_context_test_scope();
2152 with_test_runtime(|| {
2153 let state = TextFieldState::new("themed text");
2154 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
2155 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
2156 ..crate::text::SpanStyle::default()
2157 });
2158 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
2159 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
2160 ..crate::text::SpanStyle::default()
2161 });
2162 let initial = TextFieldElement::new(state, dark_style);
2163 let updated = TextFieldElement::new(state, light_style.clone());
2164 let mut node = initial.create();
2165
2166 updated.update(&mut node);
2167
2168 assert_eq!(node.text(), "themed text");
2169 assert_eq!(node.style(), &light_style);
2170 });
2171 }
2172
2173 #[test]
2178 fn multiline_field_measures_wrapped_height() {
2179 let _app_context = crate::render_state::app_context_test_scope();
2180 with_test_runtime(|| {
2181 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
2183 let node = TextFieldModifierNode::new(state, TextStyle::default());
2184 assert!(
2185 !node.line_limits().is_single_line(),
2186 "default fields are multi-line"
2187 );
2188
2189 let natural = node.measure_text_content(None);
2190 let wrapped = node.measure_text_content(node.wrap_width(20.0));
2191
2192 assert!(
2193 wrapped.height > natural.height,
2194 "wrapped multi-line height {} must exceed the single-line height {}",
2195 wrapped.height,
2196 natural.height
2197 );
2198 });
2199 }
2200
2201 #[test]
2204 fn single_line_field_never_wraps() {
2205 let _app_context = crate::render_state::app_context_test_scope();
2206 with_test_runtime(|| {
2207 let state = TextFieldState::new("abcd ".repeat(40));
2208 let node = TextFieldModifierNode::new(state, TextStyle::default())
2209 .with_line_limits(TextFieldLineLimits::SingleLine);
2210 assert_eq!(
2211 node.wrap_width(20.0),
2212 None,
2213 "single-line fields must not wrap"
2214 );
2215 });
2216 }
2217
2218 #[test]
2224 fn test_cursor_x_position_calculation() {
2225 let _app_context = crate::render_state::app_context_test_scope();
2226 with_test_runtime(|| {
2227 let style = crate::text::TextStyle::default();
2229
2230 let empty_width =
2232 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2233 assert!(
2234 empty_width.abs() < 0.1,
2235 "Empty text should have 0 width, got {}",
2236 empty_width
2237 );
2238
2239 let hi_width =
2241 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2242 assert!(
2243 hi_width > 0.0,
2244 "Text 'Hi' should have positive width: {}",
2245 hi_width
2246 );
2247
2248 let h_width =
2250 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2251 assert!(h_width > 0.0, "Text 'H' should have positive width");
2252 assert!(
2253 h_width < hi_width,
2254 "'H' width {} should be less than 'Hi' width {}",
2255 h_width,
2256 hi_width
2257 );
2258
2259 let state = TextFieldState::new("Hi");
2261 assert_eq!(
2262 state.selection().start,
2263 2,
2264 "Cursor should be at position 2 (end of 'Hi')"
2265 );
2266
2267 let text = state.text();
2269 let cursor_pos = state.selection().start;
2270 let text_before_cursor = &text[..cursor_pos.min(text.len())];
2271 assert_eq!(text_before_cursor, "Hi");
2272
2273 let cursor_x = crate::text::measure_text(
2275 &crate::text::AnnotatedString::from(text_before_cursor),
2276 &style,
2277 )
2278 .width;
2279 assert!(
2280 (cursor_x - hi_width).abs() < 0.1,
2281 "Cursor x {} should equal 'Hi' width {}",
2282 cursor_x,
2283 hi_width
2284 );
2285 });
2286 }
2287
2288 #[test]
2290 fn test_focused_node_creates_cursor() {
2291 let _app_context = crate::render_state::app_context_test_scope();
2292 with_test_runtime(|| {
2293 let state = TextFieldState::new("Test");
2294 let element = TextFieldElement::new(state, TextStyle::default());
2295 let node = element.create();
2296
2297 assert!(!node.is_focused());
2299
2300 *node.refs.is_focused.borrow_mut() = true;
2302 assert!(node.is_focused());
2303
2304 assert_eq!(node.text(), "Test");
2306
2307 assert_eq!(node.selection().start, 4);
2309 });
2310 }
2311}