1use std::{
20 cell::{Cell, RefCell},
21 hash::{Hash, Hasher},
22 rc::Rc,
23};
24
25use cranpose_core::{mutableStateOf, MutableState};
26use cranpose_foundation::{
27 text::{TextFieldLineLimits, TextFieldState, TextRange},
28 Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
29 LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
30 NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode,
31 SemanticsConfiguration, SemanticsNode, Size,
32};
33use cranpose_ui_graphics::{Brush, Color, Point};
34
35#[derive(Clone, Copy, PartialEq, Debug)]
40pub struct TextFieldHandleMetrics {
41 pub focused: bool,
42 pub direct_manipulation: bool,
45 pub node_origin: Point,
47 pub padding_left: f32,
48 pub padding_top: f32,
49 pub scroll_offset: f32,
50 pub line_height: f32,
51 pub glyph_box: (f32, f32),
55 pub wrap_width: Option<f32>,
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 press_track: Cell<Option<MutableState<Option<PointerPressTrack>>>>,
86}
87
88impl TextFieldHandleController {
89 pub fn new() -> Self {
92 Self {
93 inner: Rc::new(TextFieldHandleControllerInner {
94 metrics: Cell::new(None),
95 revision: mutableStateOf(0u64),
96 gesture_claim: RefCell::new(None),
97 press_track: Cell::new(None),
98 }),
99 }
100 }
101
102 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
105 if self.inner.metrics.get() != Some(metrics) {
106 self.inner.metrics.set(Some(metrics));
107 self.inner
108 .revision
109 .update(|value| *value = value.wrapping_add(1));
110 }
111 }
112
113 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
116 let _ = self.inner.revision.value();
117 self.inner.metrics.get()
118 }
119
120 pub(crate) fn adopt_gesture_claim(&self, claim: &Rc<Cell<bool>>) {
122 let mut slot = self.inner.gesture_claim.borrow_mut();
123 let adopted = slot.as_ref().is_some_and(|held| Rc::ptr_eq(held, claim));
124 if !adopted {
125 *slot = Some(Rc::clone(claim));
126 }
127 }
128
129 pub(crate) fn adopt_press_track(&self, press_track: MutableState<Option<PointerPressTrack>>) {
130 if self.inner.press_track.get() != Some(press_track) {
131 self.inner.press_track.set(Some(press_track));
132 self.inner
133 .revision
134 .update(|value| *value = value.wrapping_add(1));
135 }
136 }
137
138 pub fn press(&self) -> Option<PointerPressTrack> {
140 self.inner.press_track.get().and_then(|state| state.get())
141 }
142
143 pub fn claim_gesture(&self) {
146 if let Some(claim) = self.inner.gesture_claim.borrow().as_ref() {
147 claim.set(true);
148 }
149 }
150
151 pub fn gesture_claimed(&self) -> bool {
153 self.inner
154 .gesture_claim
155 .borrow()
156 .as_ref()
157 .is_some_and(|claim| claim.get())
158 }
159}
160
161impl Default for TextFieldHandleController {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
169
170const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
172
173const DEFAULT_LINE_HEIGHT: f32 = 20.0;
175
176const CURSOR_WIDTH: f32 = 2.0;
178
179pub(crate) fn compute_horizontal_scroll_offset(
190 current_offset: f32,
191 cursor_x: f32,
192 text_width: f32,
193 viewport_width: f32,
194) -> f32 {
195 if viewport_width <= 0.0 {
196 return 0.0;
197 }
198 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
199 let mut offset = current_offset.clamp(0.0, max_offset);
200 let visible_end = offset + viewport_width - CURSOR_WIDTH;
201 if cursor_x > visible_end {
202 offset = cursor_x - viewport_width + CURSOR_WIDTH;
204 } else if cursor_x < offset {
205 offset = cursor_x;
207 }
208 offset.clamp(0.0, max_offset)
209}
210
211pub(crate) fn intersect_rect(
216 rect: cranpose_ui_graphics::Rect,
217 bounds: cranpose_ui_graphics::Rect,
218) -> Option<cranpose_ui_graphics::Rect> {
219 let x0 = rect.x.max(bounds.x);
220 let y0 = rect.y.max(bounds.y);
221 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
222 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
223 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
224 x: x0,
225 y: y0,
226 width: x1 - x0,
227 height: y1 - y0,
228 })
229}
230
231pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
234
235pub(crate) fn caret_visual_line_for_offset(
245 text: &str,
246 style: &TextStyle,
247 node_id: Option<cranpose_core::NodeId>,
248 wrap_width: Option<f32>,
249 offset: usize,
250 affinity: crate::text_selection::LineAffinity,
251) -> (usize, usize) {
252 let offset = offset.min(text.len());
253 match wrap_width {
254 Some(width) if width.is_finite() && width > 0.0 => {
255 let annotated = crate::text::AnnotatedString::from(text);
256 let ranges = crate::text::wrapped_line_ranges(
257 node_id,
258 &annotated,
259 style,
260 crate::text::TextLayoutOptions::default(),
261 Some(width),
262 );
263 crate::text_selection::caret_visual_line(&ranges, offset, affinity)
264 }
265 _ => {
266 let before = &text[..offset];
269 let line_index = before.matches('\n').count();
270 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
271 (line_index, line_start)
272 }
273 }
274}
275
276#[allow(clippy::too_many_arguments)]
287pub(crate) fn range_visual_line_rects(
288 text: &str,
289 style: &TextStyle,
290 node_id: Option<cranpose_core::NodeId>,
291 wrap_width: Option<f32>,
292 padding_left: f32,
293 padding_top: f32,
294 pan: f32,
295 line_height: f32,
296 start: usize,
297 end: usize,
298) -> Vec<cranpose_ui_graphics::Rect> {
299 if start >= end {
300 return Vec::new();
301 }
302 let annotated = crate::text::AnnotatedString::from(text);
303 let line_ranges = crate::text::wrapped_line_ranges(
304 node_id,
305 &annotated,
306 style,
307 crate::text::TextLayoutOptions::default(),
308 wrap_width,
309 );
310 let mut rects = Vec::new();
311 for (line_idx, line_range) in line_ranges.iter().enumerate() {
312 let line_start = line_range.start;
313 let line_end = line_range.end;
314 if end <= line_start || start >= line_end {
315 continue;
316 }
317 let seg_start = start.max(line_start);
318 let seg_end = end.min(line_end);
319 let x0 = crate::text::measure_text(
320 &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
321 style,
322 )
323 .width
324 + padding_left
325 - pan;
326 let x1 = crate::text::measure_text(
327 &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
328 style,
329 )
330 .width
331 + padding_left
332 - pan;
333 let width = x1 - x0;
334 if width > 0.0 {
335 rects.push(cranpose_ui_graphics::Rect {
336 x: x0,
337 y: padding_top + line_idx as f32 * line_height,
338 width,
339 height: line_height,
340 });
341 }
342 }
343 rects
344}
345
346#[derive(Clone)]
352pub(crate) struct TextFieldRefs {
353 pub is_focused: Rc<RefCell<bool>>,
355 pub content_offset: Rc<Cell<f32>>,
357 pub content_y_offset: Rc<Cell<f32>>,
359 pub drag_anchor: Rc<Cell<Option<usize>>>,
361 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
363 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
365 pub click_count: Rc<Cell<u8>>,
367 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
369 pub scroll_offset: Rc<Cell<f32>>,
372 pub direct_manipulation: Rc<Cell<bool>>,
376 pub node_origin: Rc<Cell<Point>>,
380 pub line_height: Rc<Cell<f32>>,
384 pub wrap_width: Rc<Cell<Option<f32>>>,
388 pub press_track: MutableState<Option<PointerPressTrack>>,
391 pub gesture_claimed: Rc<Cell<bool>>,
395}
396
397#[derive(Clone, Copy, Debug, PartialEq)]
400pub struct PointerPressTrack {
401 pub start: Point,
403 pub position: Point,
405}
406
407impl TextFieldRefs {
408 pub fn new() -> Self {
410 Self {
411 is_focused: Rc::new(RefCell::new(false)),
412 content_offset: Rc::new(Cell::new(0.0_f32)),
413 content_y_offset: Rc::new(Cell::new(0.0_f32)),
414 drag_anchor: Rc::new(Cell::new(None::<usize>)),
415 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
416 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
417 click_count: Rc::new(Cell::new(0_u8)),
418 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
419 scroll_offset: Rc::new(Cell::new(0.0_f32)),
420 direct_manipulation: Rc::new(Cell::new(false)),
421 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
422 line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
423 wrap_width: Rc::new(Cell::new(None::<f32>)),
424 press_track: mutableStateOf(None::<PointerPressTrack>),
425 gesture_claimed: Rc::new(Cell::new(false)),
426 }
427 }
428}
429
430use crate::text::TextStyle; pub struct TextFieldModifierNode {
439 state: TextFieldState,
441 refs: TextFieldRefs,
443 style: TextStyle, cursor_brush: Brush,
447 selection_brush: Brush,
449 line_limits: TextFieldLineLimits,
451 cached_text: String,
453 cached_selection: TextRange,
455 node_state: NodeState,
457 measured_size: Rc<Cell<Size>>,
459 measured_line_height: Rc<Cell<f32>>,
461 measured_wrap_width: Rc<Cell<Option<f32>>>,
467 cached_handler: Rc<dyn Fn(PointerEvent)>,
469 cached_pan_resolver: TextPanResolver,
471 handle_controller: Option<TextFieldHandleController>,
475 modal_depth: usize,
479}
480
481impl std::fmt::Debug for TextFieldModifierNode {
482 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483 f.debug_struct("TextFieldModifierNode")
484 .field("text", &self.state.text())
485 .field("style", &self.style)
486 .field("is_focused", &*self.refs.is_focused.borrow())
487 .finish()
488 }
489}
490
491use crate::text_field_handler::TextFieldHandler;
493
494impl TextFieldModifierNode {
495 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
497 let value = state.value();
498 let refs = TextFieldRefs::new();
499 let refs_line_height = refs.line_height.clone();
500 let refs_wrap_width = refs.wrap_width.clone();
501 let line_limits = TextFieldLineLimits::default();
502 let cached_handler =
503 Self::create_handler(state, refs.clone(), line_limits, style.clone(), 0);
504 let cached_pan_resolver =
505 Self::create_pan_resolver(state, refs.clone(), line_limits, style.clone());
506
507 Self {
508 state,
509 refs,
510 style,
511 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
512 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
513 line_limits,
514 cached_text: value.text,
515 cached_selection: value.selection,
516 node_state: NodeState::new(),
517 measured_size: Rc::new(Cell::new(Size {
518 width: 0.0,
519 height: 0.0,
520 })),
521 measured_line_height: refs_line_height,
525 measured_wrap_width: refs_wrap_width,
526 cached_handler,
527 cached_pan_resolver,
528 handle_controller: None,
529 modal_depth: 0,
530 }
531 }
532
533 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
535 self.line_limits = line_limits;
536 self.rebuild_cached_closures();
537 self
538 }
539
540 fn rebuild_cached_closures(&mut self) {
549 self.cached_handler = Self::create_handler(
550 self.state,
551 self.refs.clone(),
552 self.line_limits,
553 self.style.clone(),
554 self.modal_depth,
555 );
556 self.cached_pan_resolver = Self::create_pan_resolver(
557 self.state,
558 self.refs.clone(),
559 self.line_limits,
560 self.style.clone(),
561 );
562 }
563
564 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
566 self.handle_controller = Some(controller);
567 self
568 }
569
570 fn create_pan_resolver(
578 state: TextFieldState,
579 refs: TextFieldRefs,
580 line_limits: TextFieldLineLimits,
581 style: TextStyle,
582 ) -> TextPanResolver {
583 Rc::new(move |viewport_width: f32| {
584 if !line_limits.is_single_line() {
585 refs.scroll_offset.set(0.0);
587 return 0.0;
588 }
589 let text = state.text();
590 let pos = state.selection().start.min(text.len());
591 let text_width = crate::text::measure_text(
592 &crate::text::AnnotatedString::from(text.as_str()),
593 &style,
594 )
595 .width;
596 let cursor_x = crate::text::measure_text(
597 &crate::text::AnnotatedString::from(&text[..pos]),
598 &style,
599 )
600 .width;
601 let offset = compute_horizontal_scroll_offset(
602 refs.scroll_offset.get(),
603 cursor_x,
604 text_width,
605 viewport_width,
606 );
607 refs.scroll_offset.set(offset);
608 offset
609 })
610 }
611
612 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
617 self.line_limits
618 .is_single_line()
619 .then(|| self.cached_pan_resolver.clone())
620 }
621
622 pub fn scroll_offset(&self) -> f32 {
624 self.refs.scroll_offset.get()
625 }
626
627 pub fn line_limits(&self) -> TextFieldLineLimits {
629 self.line_limits
630 }
631
632 fn create_handler(
634 state: TextFieldState,
635 refs: TextFieldRefs,
636 line_limits: TextFieldLineLimits,
637 style: TextStyle, modal_depth: usize,
639 ) -> Rc<dyn Fn(PointerEvent)> {
640 use crate::{
643 text_selection::{
644 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
645 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
646 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
647 },
648 word_boundaries::find_word_boundaries,
649 };
650
651 Rc::new(move |event: PointerEvent| {
652 refs.node_origin.set(Point {
659 x: event.global_position.x - event.position.x,
660 y: event.global_position.y - event.position.y,
661 });
662
663 let click_x =
667 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
668 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
669
670 match event.kind {
671 PointerEventKind::Down => {
672 refs.direct_manipulation.set(true);
676 refs.press_track.set(Some(PointerPressTrack {
677 start: event.global_position,
678 position: event.global_position,
679 }));
680 refs.gesture_claimed.set(false);
681
682 let handler = TextFieldHandler::new(
687 state,
688 refs.node_id.get(),
689 line_limits,
690 crate::text_field_handler::CaretGeometryRefs {
691 node_origin: refs.node_origin.clone(),
692 content_offset: refs.content_offset.clone(),
693 content_y_offset: refs.content_y_offset.clone(),
694 scroll_offset: refs.scroll_offset.clone(),
695 style: style.clone(),
696 },
697 );
698 crate::text_field_focus::request_focus(
699 refs.is_focused.clone(),
700 handler,
701 modal_depth,
702 );
703
704 let now = web_time::Instant::now();
705 let text = state.text();
706 let pos = crate::text::offset_for_position_wrapped(
707 &text,
708 &style,
709 refs.node_id.get(),
710 refs.wrap_width.get(),
711 refs.line_height.get(),
712 click_x,
713 click_y,
714 );
715
716 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
721 let count = refs.click_count.get();
722 (count > 0).then_some((count, px, py))
723 });
724 let elapsed_ms = refs
725 .last_click_time
726 .get()
727 .map(|last| now.duration_since(last).as_millis())
728 .unwrap_or(u128::MAX);
729 let tap_count = classify_tap_count(
730 previous,
731 elapsed_ms,
732 event.position.x,
733 event.position.y,
734 MULTI_TAP_TIMEOUT_MS,
735 MULTI_TAP_SLOP_PX,
736 );
737
738 let selection = state.selection();
746 let tap_in_selection =
747 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
748 let repeat_in_place = refs
751 .last_click_pos
752 .get()
753 .map(|(px, py)| {
754 let dx = event.position.x - px;
755 let dy = event.position.y - py;
756 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
757 })
758 .unwrap_or(false);
759 let effective_count = resolve_selection_tap_count(
760 tap_count,
761 refs.click_count.get(),
762 tap_in_selection,
763 repeat_in_place,
764 );
765
766 match tap_selection_granularity(effective_count) {
767 SelectionGranularity::Paragraph => {
768 let (start, end) = find_paragraph_boundaries(&text, pos);
770 state.edit(|buffer| {
771 buffer.select(TextRange::new(start, end));
772 });
773 refs.drag_anchor.set(Some(start));
774 }
775 SelectionGranularity::Line => {
776 let (line_start, line_end) = find_line_boundaries(&text, pos);
778 state.edit(|buffer| {
779 buffer.select(TextRange::new(line_start, line_end));
780 });
781 refs.drag_anchor.set(Some(line_start));
782 }
783 SelectionGranularity::Word => {
784 let (word_start, word_end) = find_word_boundaries(&text, pos);
787 state.edit(|buffer| {
788 buffer.select(TextRange::new(word_start, word_end));
789 });
790 refs.drag_anchor.set(Some(word_start));
791 }
792 SelectionGranularity::Caret => {
793 refs.drag_anchor.set(Some(pos));
795 state.edit(|buffer| {
796 buffer.place_cursor_before_char(pos);
797 });
798 }
799 }
800
801 refs.click_count.set(effective_count);
802 refs.last_click_time.set(Some(now));
803 refs.last_click_pos
804 .set(Some((event.position.x, event.position.y)));
805 event.consume();
806 }
807 PointerEventKind::Move => {
808 if let Some(mut track) = refs.press_track.get() {
810 track.position = event.global_position;
811 refs.press_track.set(Some(track));
812 crate::request_render_invalidation();
813 }
814 if refs.gesture_claimed.get() {
817 event.consume();
818 return;
819 }
820 if let Some(anchor) = refs.drag_anchor.get() {
822 if *refs.is_focused.borrow() {
823 let text = state.text();
824 let current_pos = crate::text::offset_for_position_wrapped(
825 &text,
826 &style,
827 refs.node_id.get(),
828 refs.wrap_width.get(),
829 refs.line_height.get(),
830 click_x,
831 click_y,
832 );
833
834 state.set_selection(TextRange::new(anchor, current_pos));
836
837 crate::request_render_invalidation();
839
840 event.consume();
841 }
842 }
843 }
844 PointerEventKind::Up => {
845 refs.drag_anchor.set(None);
847 refs.press_track.set(None);
848 refs.gesture_claimed.set(false);
849 crate::request_render_invalidation();
855 }
856 PointerEventKind::Cancel => {
857 refs.press_track.set(None);
858 refs.gesture_claimed.set(false);
859 crate::request_render_invalidation();
860 }
861 _ => {}
862 }
863 })
864 }
865
866 pub fn with_cursor_color(mut self, color: Color) -> Self {
871 self.cursor_brush = Brush::solid(color);
872 self.selection_brush = Brush::solid(
873 color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
874 );
875 self
876 }
877
878 pub fn set_focused(&mut self, focused: bool) {
880 let current = *self.refs.is_focused.borrow();
881 if current != focused {
882 *self.refs.is_focused.borrow_mut() = focused;
883 if !focused {
884 self.refs.direct_manipulation.set(false);
885 self.refs.press_track.set(None);
886 self.refs.gesture_claimed.set(false);
887 }
888 }
889 }
890
891 pub fn is_focused(&self) -> bool {
893 *self.refs.is_focused.borrow()
894 }
895
896 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
909 self.refs.node_origin.clone()
910 }
911
912 pub fn text(&self) -> String {
914 self.state.text()
915 }
916
917 pub fn style(&self) -> &TextStyle {
918 &self.style
919 }
920
921 pub fn selection(&self) -> TextRange {
923 self.state.selection()
924 }
925
926 pub fn cursor_brush(&self) -> Brush {
928 self.cursor_brush.clone()
929 }
930
931 pub fn selection_brush(&self) -> Brush {
933 self.selection_brush.clone()
934 }
935
936 pub fn insert_text(&mut self, text: &str) {
938 self.state.edit(|buffer| {
939 buffer.insert(text);
940 });
941 }
942
943 pub fn copy_selection(&self) -> Option<String> {
946 self.state.copy_selection()
947 }
948
949 pub fn cut_selection(&mut self) -> Option<String> {
952 let text = self.copy_selection();
953 if text.is_some() {
954 self.state.edit(|buffer| {
955 buffer.delete(buffer.selection());
956 });
957 }
958 text
959 }
960
961 pub fn set_content_offset(&self, offset: f32) {
964 self.refs.content_offset.set(offset);
965 }
966
967 pub fn set_content_y_offset(&self, offset: f32) {
970 self.refs.content_y_offset.set(offset);
971 }
972
973 fn wrap_width(&self, available_width: f32) -> Option<f32> {
980 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
981 .then_some(available_width)
982 }
983
984 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
990 let text = self.state.text();
991 let node_id = self.refs.node_id.get();
992 let annotated = crate::text::AnnotatedString::from(text.as_str());
993 let metrics = match wrap_width {
994 Some(max_width) => crate::text::measure_text_with_options_for_node(
995 node_id,
996 &annotated,
997 &self.style,
998 crate::text::TextLayoutOptions::default(),
999 Some(max_width),
1000 ),
1001 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
1002 };
1003 self.measured_line_height.set(metrics.line_height);
1004 Size {
1005 width: metrics.width,
1006 height: metrics.height,
1007 }
1008 }
1009
1010 fn update_cached_state(&mut self) -> bool {
1012 let value = self.state.value();
1013 let text_changed = value.text != self.cached_text;
1014 let selection_changed = value.selection != self.cached_selection;
1015
1016 if text_changed {
1017 self.cached_text = value.text;
1018 }
1019 if selection_changed {
1020 self.cached_selection = value.selection;
1021 }
1022
1023 text_changed || selection_changed
1024 }
1025
1026 }
1030
1031impl DelegatableNode for TextFieldModifierNode {
1032 fn node_state(&self) -> &NodeState {
1033 &self.node_state
1034 }
1035}
1036
1037impl ModifierNode for TextFieldModifierNode {
1038 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
1039 self.refs.node_id.set(context.node_id());
1041
1042 context.invalidate(InvalidationKind::Layout);
1043 context.invalidate(InvalidationKind::Draw);
1044 context.invalidate(InvalidationKind::Semantics);
1045 }
1046
1047 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
1048 Some(self)
1049 }
1050
1051 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
1052 Some(self)
1053 }
1054
1055 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
1056 Some(self)
1057 }
1058
1059 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
1060 Some(self)
1061 }
1062
1063 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
1064 Some(self)
1065 }
1066
1067 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
1068 Some(self)
1069 }
1070
1071 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
1072 Some(self)
1073 }
1074
1075 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
1076 Some(self)
1077 }
1078}
1079
1080impl LayoutModifierNode for TextFieldModifierNode {
1081 fn measure(
1082 &self,
1083 _context: &mut dyn ModifierNodeContext,
1084 _measurable: &dyn Measurable,
1085 constraints: Constraints,
1086 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
1087 let wrap_width = self.wrap_width(constraints.max_width);
1091 self.measured_wrap_width.set(wrap_width);
1094 let text_size = self.measure_text_content(wrap_width);
1095
1096 let min_height = if text_size.height < 1.0 {
1098 DEFAULT_LINE_HEIGHT
1099 } else {
1100 text_size.height
1101 };
1102
1103 let width = text_size
1105 .width
1106 .max(constraints.min_width)
1107 .min(constraints.max_width);
1108 let height = min_height
1109 .max(constraints.min_height)
1110 .min(constraints.max_height);
1111
1112 let size = Size { width, height };
1113 self.measured_size.set(size);
1114
1115 let _ = (self.cached_pan_resolver)(size.width);
1118
1119 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
1120 }
1121
1122 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1123 self.measure_text_content(None).width
1124 }
1125
1126 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1127 self.measure_text_content(None).width
1128 }
1129
1130 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1131 self.measure_text_content(self.wrap_width(width))
1132 .height
1133 .max(DEFAULT_LINE_HEIGHT)
1134 }
1135
1136 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1137 self.measure_text_content(self.wrap_width(width))
1138 .height
1139 .max(DEFAULT_LINE_HEIGHT)
1140 }
1141}
1142
1143fn content_viewport(
1147 measured: cranpose_ui_graphics::Size,
1148 size: cranpose_foundation::Size,
1149 padding_left: f32,
1150 padding_top: f32,
1151) -> (f32, f32) {
1152 let width = if measured.width > 0.0 {
1153 measured.width
1154 } else {
1155 (size.width - padding_left).max(0.0)
1156 };
1157 let height = if measured.height > 0.0 {
1158 measured.height
1159 } else {
1160 (size.height - padding_top).max(0.0)
1161 };
1162 (width, height)
1163}
1164
1165impl DrawModifierNode for TextFieldModifierNode {
1166 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
1167 }
1171
1172 fn create_draw_closure(
1173 &self,
1174 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1175 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1176
1177 let is_focused = self.refs.is_focused.clone();
1179 let state = self.state;
1180 let content_offset = self.refs.content_offset.clone();
1181 let content_y_offset = self.refs.content_y_offset.clone();
1182 let cursor_brush = self.cursor_brush.clone();
1183 let style = self.style.clone();
1184 let cached_line_height = self.measured_line_height.clone();
1185 let measured_size = self.measured_size.clone();
1186 let measured_wrap_width = self.measured_wrap_width.clone();
1187 let node_id = self.refs.node_id.clone();
1188 let pan_resolver = self.cached_pan_resolver.clone();
1189 let handle_controller = self.handle_controller.clone();
1190 let node_origin = self.refs.node_origin.clone();
1191 let direct_manipulation = self.refs.direct_manipulation.clone();
1192 let press_track = self.refs.press_track;
1193 let gesture_claimed = self.refs.gesture_claimed.clone();
1194
1195 Some(Rc::new(move |scope| {
1196 let size = scope.size();
1197 if !*is_focused.borrow() {
1199 if let Some(controller) = &handle_controller {
1202 controller.publish(TextFieldHandleMetrics {
1203 focused: false,
1204 direct_manipulation: false,
1205 node_origin: node_origin.get(),
1206 padding_left: 0.0,
1207 padding_top: 0.0,
1208 scroll_offset: 0.0,
1209 line_height: cached_line_height.get(),
1210 glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
1211 wrap_width: measured_wrap_width.get(),
1212 });
1213 }
1214 return;
1215 }
1216
1217 let mut primitives = Vec::new();
1218
1219 let text = state.text();
1220 let selection = state.selection();
1221 let padding_left = content_offset.get();
1222 let padding_top = content_y_offset.get();
1223 let line_height = cached_line_height.get();
1226
1227 let (viewport_width, viewport_height) =
1228 content_viewport(measured_size.get(), size, padding_left, padding_top);
1229 let pan = pan_resolver(viewport_width);
1231
1232 if let Some(controller) = &handle_controller {
1235 controller.adopt_gesture_claim(&gesture_claimed);
1236 controller.adopt_press_track(press_track);
1237 controller.publish(TextFieldHandleMetrics {
1238 focused: true,
1239 direct_manipulation: direct_manipulation.get(),
1240 node_origin: node_origin.get(),
1241 padding_left,
1242 padding_top,
1243 scroll_offset: pan,
1244 line_height,
1245 glyph_box: crate::text::glyph_line_box(&style, line_height),
1246 wrap_width: measured_wrap_width.get(),
1247 });
1248 }
1249 let clip_bounds = cranpose_ui_graphics::Rect {
1253 x: padding_left,
1254 y: padding_top,
1255 width: viewport_width,
1256 height: viewport_height,
1257 };
1258
1259 if let Some(comp_range) = state.composition() {
1266 let comp_start = comp_range.min();
1267 let comp_end = comp_range.max();
1268
1269 if comp_start < comp_end && comp_end <= text.len() {
1270 let underline_brush = cranpose_ui_graphics::Brush::solid(
1272 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1273 );
1274 let underline_height: f32 = 2.0;
1275
1276 for line_rect in range_visual_line_rects(
1279 &text,
1280 &style,
1281 node_id.get(),
1282 measured_wrap_width.get(),
1283 padding_left,
1284 padding_top,
1285 pan,
1286 line_height,
1287 comp_start,
1288 comp_end,
1289 ) {
1290 let underline_rect = cranpose_ui_graphics::Rect {
1291 x: line_rect.x,
1292 y: line_rect.y + line_height - underline_height,
1293 width: line_rect.width,
1294 height: underline_height,
1295 };
1296 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1297 primitives.push(DrawPrimitive::Rect {
1298 rect: clipped,
1299 brush: underline_brush.clone(),
1300 stroke: None,
1301 });
1302 }
1303 }
1304 }
1305 }
1306
1307 if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1313 let pos = selection.start.min(text.len());
1314 let (line_index, line_start) = caret_visual_line_for_offset(
1323 &text,
1324 &style,
1325 node_id.get(),
1326 measured_wrap_width.get(),
1327 pos,
1328 crate::text_selection::LineAffinity::Upstream,
1329 );
1330 let cursor_x = crate::text::measure_text(
1331 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1332 &style,
1333 )
1334 .width
1335 + padding_left
1336 - pan;
1337 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1340 let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1341
1342 let cursor_rect = cranpose_ui_graphics::Rect {
1343 x: cursor_x,
1344 y: cursor_y,
1345 width: CURSOR_WIDTH,
1346 height: box_h,
1347 };
1348
1349 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1350 primitives.push(DrawPrimitive::Rect {
1351 rect: clipped,
1352 brush: cursor_brush.clone(),
1353 stroke: None,
1354 });
1355 }
1356 }
1357
1358 scope.push_recorded(primitives);
1359 }))
1360 }
1361
1362 fn create_behind_draw_closure(
1363 &self,
1364 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1365 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1366
1367 let is_focused = self.refs.is_focused.clone();
1368 let state = self.state;
1369 let content_offset = self.refs.content_offset.clone();
1370 let content_y_offset = self.refs.content_y_offset.clone();
1371 let selection_brush = self.selection_brush.clone();
1372 let style = self.style.clone();
1373 let cached_line_height = self.measured_line_height.clone();
1374 let measured_size = self.measured_size.clone();
1375 let measured_wrap_width = self.measured_wrap_width.clone();
1376 let node_id = self.refs.node_id.clone();
1377 let pan_resolver = self.cached_pan_resolver.clone();
1378
1379 Some(Rc::new(move |scope| {
1380 let size = scope.size();
1381 if !*is_focused.borrow() {
1382 return;
1383 }
1384 let selection = state.selection();
1385 if selection.collapsed() {
1386 return;
1387 }
1388 let text = state.text();
1389 let padding_left = content_offset.get();
1390 let padding_top = content_y_offset.get();
1391 let line_height = cached_line_height.get();
1392 let (viewport_width, viewport_height) =
1393 content_viewport(measured_size.get(), size, padding_left, padding_top);
1394 let pan = pan_resolver(viewport_width);
1395 let clip_bounds = cranpose_ui_graphics::Rect {
1396 x: padding_left,
1397 y: padding_top,
1398 width: viewport_width,
1399 height: viewport_height,
1400 };
1401
1402 let mut primitives = Vec::new();
1406 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1410 for sel_rect in range_visual_line_rects(
1411 &text,
1412 &style,
1413 node_id.get(),
1414 measured_wrap_width.get(),
1415 padding_left,
1416 padding_top,
1417 pan,
1418 line_height,
1419 selection.min(),
1420 selection.max(),
1421 ) {
1422 let sel_rect = cranpose_ui_graphics::Rect {
1423 y: sel_rect.y + box_off,
1424 height: box_h,
1425 ..sel_rect
1426 };
1427 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1428 primitives.push(DrawPrimitive::Rect {
1429 rect: clipped,
1430 brush: selection_brush.clone(),
1431 stroke: None,
1432 });
1433 }
1434 }
1435 scope.push_recorded(primitives);
1436 }))
1437 }
1438}
1439
1440impl SemanticsNode for TextFieldModifierNode {
1441 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1442 let text = self.state.text();
1443 config.content_description = Some(text);
1444 config.is_editable_text = true;
1445 config.text_selection = Some(self.state.selection());
1446 }
1447}
1448
1449impl PointerInputNode for TextFieldModifierNode {
1450 fn on_pointer_event(
1451 &mut self,
1452 _context: &mut dyn ModifierNodeContext,
1453 _event: &PointerEvent,
1454 ) -> bool {
1455 false
1466 }
1467
1468 fn hit_test(&self, x: f32, y: f32) -> bool {
1469 let size = self.measured_size.get();
1471 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1472 }
1473
1474 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1475 Some(self.cached_handler.clone())
1477 }
1478}
1479
1480#[derive(Clone)]
1491pub struct TextFieldElement {
1492 state: TextFieldState,
1494 style: TextStyle,
1496 cursor_color: Color,
1498 line_limits: TextFieldLineLimits,
1500 handle_controller: Option<TextFieldHandleController>,
1503 modal_depth: usize,
1505}
1506
1507impl TextFieldElement {
1508 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1510 Self {
1511 state,
1512 style,
1513 cursor_color: DEFAULT_CURSOR_COLOR,
1514 line_limits: TextFieldLineLimits::default(),
1515 handle_controller: None,
1516 modal_depth: 0,
1517 }
1518 }
1519
1520 pub fn with_cursor_color(mut self, color: Color) -> Self {
1522 self.cursor_color = color;
1523 self
1524 }
1525
1526 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1528 self.line_limits = line_limits;
1529 self
1530 }
1531
1532 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1534 self.handle_controller = Some(controller);
1535 self
1536 }
1537
1538 pub fn with_modal_depth(mut self, depth: usize) -> Self {
1541 self.modal_depth = depth;
1542 self
1543 }
1544}
1545
1546impl std::fmt::Debug for TextFieldElement {
1547 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1548 f.debug_struct("TextFieldElement")
1549 .field("text", &self.state.text())
1550 .field("style", &self.style)
1551 .field("cursor_color", &self.cursor_color)
1552 .finish()
1553 }
1554}
1555
1556impl Hash for TextFieldElement {
1557 fn hash<H: Hasher>(&self, state: &mut H) {
1558 self.state.id().hash(state);
1561 self.cursor_color.0.to_bits().hash(state);
1563 self.cursor_color.1.to_bits().hash(state);
1564 self.cursor_color.2.to_bits().hash(state);
1565 self.cursor_color.3.to_bits().hash(state);
1566 self.style.render_hash().hash(state);
1567 self.line_limits.hash(state);
1568 self.modal_depth.hash(state);
1569 }
1570}
1571
1572impl PartialEq for TextFieldElement {
1573 fn eq(&self, other: &Self) -> bool {
1574 self.state == other.state
1578 && self.style == other.style
1579 && self.cursor_color == other.cursor_color
1580 && self.line_limits == other.line_limits
1581 && self.modal_depth == other.modal_depth
1582 }
1583}
1584
1585impl Eq for TextFieldElement {}
1586
1587impl ModifierNodeElement for TextFieldElement {
1588 type Node = TextFieldModifierNode;
1589
1590 fn create(&self) -> Self::Node {
1591 let mut node = TextFieldModifierNode::new(self.state, self.style.clone())
1592 .with_cursor_color(self.cursor_color)
1593 .with_line_limits(self.line_limits);
1594 node.modal_depth = self.modal_depth;
1595 if let Some(controller) = self.handle_controller.clone() {
1596 node = node.with_handle_controller(controller);
1597 }
1598 node.rebuild_cached_closures();
1599 node
1600 }
1601
1602 fn update(&self, node: &mut Self::Node) {
1603 node.state = self.state;
1605 node.style = self.style.clone();
1606 node.cursor_brush = Brush::solid(self.cursor_color);
1607 node.line_limits = self.line_limits;
1608 node.handle_controller = self.handle_controller.clone();
1609 node.modal_depth = self.modal_depth;
1610 node.rebuild_cached_closures();
1611
1612 if node.update_cached_state() {
1614 }
1617 }
1618
1619 fn capabilities(&self) -> NodeCapabilities {
1620 NodeCapabilities::LAYOUT
1621 | NodeCapabilities::DRAW
1622 | NodeCapabilities::SEMANTICS
1623 | NodeCapabilities::POINTER_INPUT
1624 }
1625
1626 fn always_update(&self) -> bool {
1627 true
1629 }
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634 use std::sync::Arc;
1635
1636 use cranpose_core::{DefaultScheduler, Runtime};
1637
1638 use super::*;
1639 use crate::text::TextStyle;
1640
1641 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1643 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1644 f()
1645 }
1646
1647 #[test]
1648 fn text_field_node_creation() {
1649 let _app_context = crate::render_state::app_context_test_scope();
1650 with_test_runtime(|| {
1651 let state = TextFieldState::new("Hello");
1652 let node = TextFieldModifierNode::new(state, TextStyle::default());
1653 assert_eq!(node.text(), "Hello");
1654 assert!(!node.is_focused());
1655 });
1656 }
1657
1658 #[test]
1663 fn selection_rects_follow_wrapped_visual_lines() {
1664 let _app_context = crate::render_state::app_context_test_scope();
1665 let text = "aaaaa\nbb";
1668 let style = TextStyle::default();
1669 let line_height = 10.0_f32;
1670
1671 let rects = range_visual_line_rects(
1674 text,
1675 &style,
1676 None,
1677 Some(30.0),
1678 0.0,
1679 0.0,
1680 0.0,
1681 line_height,
1682 6,
1683 8,
1684 );
1685 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1686 assert_eq!(
1687 rects[0].y,
1688 2.0 * line_height,
1689 "highlight must land on visual line 2, not logical line 1"
1690 );
1691 assert!(rects[0].width > 0.0);
1692
1693 let spanning = range_visual_line_rects(
1696 text,
1697 &style,
1698 None,
1699 Some(30.0),
1700 0.0,
1701 0.0,
1702 0.0,
1703 line_height,
1704 0,
1705 5,
1706 );
1707 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1708 assert_eq!(spanning[0].y, 0.0);
1709 assert_eq!(spanning[1].y, line_height);
1710 }
1711
1712 #[test]
1717 fn tap_resolves_offset_on_wrapped_visual_line() {
1718 let _app_context = crate::render_state::app_context_test_scope();
1719 let text = "aaaaa\nbb";
1722 let style = TextStyle::default();
1723 let line_height = 10.0_f32;
1724
1725 let off = crate::text::offset_for_position_wrapped(
1728 text,
1729 &style,
1730 None,
1731 Some(30.0),
1732 line_height,
1733 8.0,
1734 22.0,
1735 );
1736 assert!(
1737 (6..=8).contains(&off),
1738 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1739 );
1740
1741 let off1 = crate::text::offset_for_position_wrapped(
1744 text,
1745 &style,
1746 None,
1747 Some(30.0),
1748 line_height,
1749 4.0,
1750 12.0,
1751 );
1752 assert!(
1753 (3..=5).contains(&off1),
1754 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1755 );
1756
1757 let off2 = crate::text::offset_for_position_wrapped(
1759 "hello",
1760 &style,
1761 None,
1762 None,
1763 line_height,
1764 0.0,
1765 0.0,
1766 );
1767 assert_eq!(off2, 0);
1768 }
1769
1770 #[test]
1771 fn text_field_node_focus() {
1772 let _app_context = crate::render_state::app_context_test_scope();
1773 with_test_runtime(|| {
1774 let state = TextFieldState::new("Test");
1775 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1776 assert!(!node.is_focused());
1777
1778 node.set_focused(true);
1779 assert!(node.is_focused());
1780
1781 node.set_focused(false);
1782 assert!(!node.is_focused());
1783 });
1784 }
1785
1786 #[test]
1787 fn text_field_element_creates_node() {
1788 let _app_context = crate::render_state::app_context_test_scope();
1789 with_test_runtime(|| {
1790 let state = TextFieldState::new("Hello World");
1791 let element = TextFieldElement::new(state, TextStyle::default());
1792
1793 let node = element.create();
1794 assert_eq!(node.text(), "Hello World");
1795 });
1796 }
1797
1798 #[test]
1803 fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1804 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1805 use cranpose_ui_graphics::Point;
1806
1807 let _app_context = crate::render_state::app_context_test_scope();
1808 with_test_runtime(|| {
1809 let state = TextFieldState::new("hello world");
1810 let controller = TextFieldHandleController::new();
1811 let mut node = TextFieldModifierNode::new(state, TextStyle::default())
1812 .with_handle_controller(controller.clone());
1813 node.measured_size.set(Size {
1815 width: 120.0,
1816 height: 20.0,
1817 });
1818
1819 let handler = node
1820 .pointer_input_handler()
1821 .expect("field exposes a pointer handler");
1822 let draw = node
1823 .create_draw_closure()
1824 .expect("field exposes a draw closure");
1825 let at = Point { x: 12.0, y: 8.0 };
1826 let size = Size {
1827 width: 120.0,
1828 height: 20.0,
1829 };
1830 let run_draw = || {
1833 let mut scope = crate::draw::command_draw_scope(size);
1834 draw(&mut scope);
1835 };
1836
1837 node.set_focused(true);
1838 run_draw();
1839 let keyboard_metrics = controller
1840 .metrics()
1841 .expect("focused field publishes handle metrics");
1842 assert!(!keyboard_metrics.direct_manipulation);
1843
1844 handler(
1845 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1846 );
1847 run_draw();
1848 let metrics = controller
1849 .metrics()
1850 .expect("focused field publishes handle metrics");
1851 assert!(metrics.focused, "a tap focuses the field");
1852 assert!(
1853 metrics.direct_manipulation,
1854 "a touch tap must expose direct-manipulation handles"
1855 );
1856 assert!(
1857 controller.press().is_some(),
1858 "touch must publish the live press"
1859 );
1860
1861 handler(
1862 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1863 );
1864 run_draw();
1865 let metrics = controller
1866 .metrics()
1867 .expect("focused field publishes handle metrics");
1868 assert!(
1869 metrics.direct_manipulation,
1870 "a mouse tap must expose the same direct-manipulation handles"
1871 );
1872 assert!(
1873 controller.press().is_some(),
1874 "mouse must publish the live press"
1875 );
1876
1877 handler(
1878 PointerEvent::new(PointerEventKind::Down, at, at)
1879 .with_source(PointerSource::Stylus),
1880 );
1881 run_draw();
1882 let metrics = controller
1883 .metrics()
1884 .expect("focused field publishes handle metrics");
1885 assert!(
1886 metrics.direct_manipulation,
1887 "a stylus contact must expose the same direct-manipulation handles"
1888 );
1889 assert!(
1890 controller.press().is_some(),
1891 "stylus must publish the live press"
1892 );
1893
1894 crate::text_field_focus::clear_focus();
1895 });
1896 }
1897
1898 #[test]
1906 fn double_tap_selects_the_word_under_the_finger() {
1907 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1908 use cranpose_ui_graphics::Point;
1909
1910 let _app_context = crate::render_state::app_context_test_scope();
1911 with_test_runtime(|| {
1912 let state = TextFieldState::new("hello world");
1913 let node = TextFieldModifierNode::new(state, TextStyle::default());
1914 node.measured_size.set(Size {
1915 width: 200.0,
1916 height: 20.0,
1917 });
1918 let handler = node
1919 .pointer_input_handler()
1920 .expect("field exposes a pointer handler");
1921
1922 let at = Point { x: 2.0, y: 8.0 };
1925 handler(
1926 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1927 );
1928 handler(
1929 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1930 );
1931
1932 let selection = state.selection();
1933 assert!(
1934 !selection.collapsed(),
1935 "a double tap must produce a (word) selection, got {selection:?}"
1936 );
1937 let selected = &state.text()[selection.min()..selection.max()];
1938 assert_eq!(
1939 selected, "hello",
1940 "double tap should select the whole word under the finger"
1941 );
1942
1943 crate::text_field_focus::clear_focus();
1944 });
1945 }
1946
1947 #[test]
1951 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1952 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1953 use cranpose_ui_graphics::Point;
1954
1955 let _app_context = crate::render_state::app_context_test_scope();
1956 with_test_runtime(|| {
1957 let text = "alpha beta\ngamma delta\n\nsecond para";
1960 let state = TextFieldState::new(text);
1961 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
1962 TextFieldLineLimits::MultiLine {
1963 min_lines: 1,
1964 max_lines: usize::MAX,
1965 },
1966 );
1967 node.measured_size.set(Size {
1968 width: 400.0,
1969 height: 80.0,
1970 });
1971 let handler = node
1972 .pointer_input_handler()
1973 .expect("field exposes a pointer handler");
1974
1975 let at = Point { x: 2.0, y: 4.0 };
1977 let tap = || {
1978 handler(
1979 PointerEvent::new(PointerEventKind::Down, at, at)
1980 .with_source(PointerSource::Touch),
1981 );
1982 };
1983 let selected = |state: &TextFieldState| {
1984 let s = state.selection();
1985 state.text()[s.min()..s.max()].to_string()
1986 };
1987
1988 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1990 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1992 tap(); assert_eq!(
1994 selected(&state),
1995 "alpha beta",
1996 "triple tap selects the line"
1997 );
1998 tap(); assert_eq!(
2000 selected(&state),
2001 "alpha beta\ngamma delta",
2002 "fourth tap grows to the paragraph"
2003 );
2004 tap(); assert_eq!(
2006 selected(&state),
2007 "alpha",
2008 "fifth tap cycles back to the word"
2009 );
2010
2011 crate::text_field_focus::clear_focus();
2012 });
2013 }
2014
2015 #[test]
2019 fn single_tap_inside_selection_selects_the_word() {
2020 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2021 use cranpose_ui_graphics::Point;
2022
2023 let _app_context = crate::render_state::app_context_test_scope();
2024 with_test_runtime(|| {
2025 let state = TextFieldState::new("hello world");
2026 let node = TextFieldModifierNode::new(state, TextStyle::default());
2027 node.measured_size.set(Size {
2028 width: 200.0,
2029 height: 20.0,
2030 });
2031 let handler = node
2032 .pointer_input_handler()
2033 .expect("field exposes a pointer handler");
2034
2035 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
2037 assert!(!state.selection().collapsed());
2038
2039 let at = Point { x: 2.0, y: 8.0 };
2042 handler(
2043 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
2044 );
2045
2046 let selection = state.selection();
2047 assert!(
2048 !selection.collapsed(),
2049 "a tap inside a selection must not collapse it, got {selection:?}"
2050 );
2051 assert_eq!(
2052 &state.text()[selection.min()..selection.max()],
2053 "hello",
2054 "a tap inside a selection re-selects the word under the finger"
2055 );
2056
2057 crate::text_field_focus::clear_focus();
2058 });
2059 }
2060
2061 #[test]
2069 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
2070 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2071 use cranpose_ui_graphics::Point;
2072
2073 let _app_context = crate::render_state::app_context_test_scope();
2074 with_test_runtime(|| {
2075 let text = "alpha beta\ngamma delta\n\nsecond para";
2076 let state = TextFieldState::new(text);
2077 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
2078 TextFieldLineLimits::MultiLine {
2079 min_lines: 1,
2080 max_lines: usize::MAX,
2081 },
2082 );
2083 node.measured_size.set(Size {
2084 width: 400.0,
2085 height: 80.0,
2086 });
2087 let handler = node
2088 .pointer_input_handler()
2089 .expect("field exposes a pointer handler");
2090
2091 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
2093
2094 let at = Point { x: 2.0, y: 4.0 };
2095 let selected = |state: &TextFieldState| {
2096 let s = state.selection();
2097 state.text()[s.min()..s.max()].to_string()
2098 };
2099 let slow_tap = || {
2102 node.refs.last_click_time.set(None);
2103 handler(
2104 PointerEvent::new(PointerEventKind::Down, at, at)
2105 .with_source(PointerSource::Touch),
2106 );
2107 };
2108
2109 slow_tap(); assert_eq!(
2111 selected(&state),
2112 "alpha",
2113 "tap inside selection grabs the word"
2114 );
2115 slow_tap(); assert_eq!(
2117 selected(&state),
2118 "alpha beta",
2119 "same-spot tap grows to the line even after the timeout"
2120 );
2121 slow_tap(); assert_eq!(
2123 selected(&state),
2124 "alpha beta\ngamma delta",
2125 "same-spot tap grows to the paragraph"
2126 );
2127 slow_tap(); assert_eq!(
2129 selected(&state),
2130 "alpha",
2131 "same-spot tap cycles back to the word"
2132 );
2133
2134 crate::text_field_focus::clear_focus();
2135 });
2136 }
2137
2138 #[test]
2139 fn text_field_element_equality() {
2140 let _app_context = crate::render_state::app_context_test_scope();
2141 with_test_runtime(|| {
2142 let state1 = TextFieldState::new("Hello");
2143 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1, TextStyle::default());
2146 let elem2 = TextFieldElement::new(state1, TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
2152 assert_ne!(elem1, elem3, "Different states should not be equal");
2153 });
2154 }
2155
2156 #[test]
2157 fn text_field_element_update_refreshes_existing_node_style() {
2158 let _app_context = crate::render_state::app_context_test_scope();
2159 with_test_runtime(|| {
2160 let state = TextFieldState::new("themed text");
2161 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
2162 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
2163 ..crate::text::SpanStyle::default()
2164 });
2165 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
2166 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
2167 ..crate::text::SpanStyle::default()
2168 });
2169 let initial = TextFieldElement::new(state, dark_style);
2170 let updated = TextFieldElement::new(state, light_style.clone());
2171 let mut node = initial.create();
2172
2173 updated.update(&mut node);
2174
2175 assert_eq!(node.text(), "themed text");
2176 assert_eq!(node.style(), &light_style);
2177 });
2178 }
2179
2180 #[test]
2185 fn multiline_field_measures_wrapped_height() {
2186 let _app_context = crate::render_state::app_context_test_scope();
2187 with_test_runtime(|| {
2188 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
2190 let node = TextFieldModifierNode::new(state, TextStyle::default());
2191 assert!(
2192 !node.line_limits().is_single_line(),
2193 "default fields are multi-line"
2194 );
2195
2196 let natural = node.measure_text_content(None);
2197 let wrapped = node.measure_text_content(node.wrap_width(20.0));
2198
2199 assert!(
2200 wrapped.height > natural.height,
2201 "wrapped multi-line height {} must exceed the single-line height {}",
2202 wrapped.height,
2203 natural.height
2204 );
2205 });
2206 }
2207
2208 #[test]
2211 fn single_line_field_never_wraps() {
2212 let _app_context = crate::render_state::app_context_test_scope();
2213 with_test_runtime(|| {
2214 let state = TextFieldState::new("abcd ".repeat(40));
2215 let node = TextFieldModifierNode::new(state, TextStyle::default())
2216 .with_line_limits(TextFieldLineLimits::SingleLine);
2217 assert_eq!(
2218 node.wrap_width(20.0),
2219 None,
2220 "single-line fields must not wrap"
2221 );
2222 });
2223 }
2224
2225 #[test]
2231 fn test_cursor_x_position_calculation() {
2232 let _app_context = crate::render_state::app_context_test_scope();
2233 with_test_runtime(|| {
2234 let style = crate::text::TextStyle::default();
2236
2237 let empty_width =
2239 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2240 assert!(
2241 empty_width.abs() < 0.1,
2242 "Empty text should have 0 width, got {}",
2243 empty_width
2244 );
2245
2246 let hi_width =
2248 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2249 assert!(
2250 hi_width > 0.0,
2251 "Text 'Hi' should have positive width: {}",
2252 hi_width
2253 );
2254
2255 let h_width =
2257 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2258 assert!(h_width > 0.0, "Text 'H' should have positive width");
2259 assert!(
2260 h_width < hi_width,
2261 "'H' width {} should be less than 'Hi' width {}",
2262 h_width,
2263 hi_width
2264 );
2265
2266 let state = TextFieldState::new("Hi");
2268 assert_eq!(
2269 state.selection().start,
2270 2,
2271 "Cursor should be at position 2 (end of 'Hi')"
2272 );
2273
2274 let text = state.text();
2276 let cursor_pos = state.selection().start;
2277 let text_before_cursor = &text[..cursor_pos.min(text.len())];
2278 assert_eq!(text_before_cursor, "Hi");
2279
2280 let cursor_x = crate::text::measure_text(
2282 &crate::text::AnnotatedString::from(text_before_cursor),
2283 &style,
2284 )
2285 .width;
2286 assert!(
2287 (cursor_x - hi_width).abs() < 0.1,
2288 "Cursor x {} should equal 'Hi' width {}",
2289 cursor_x,
2290 hi_width
2291 );
2292 });
2293 }
2294
2295 #[test]
2297 fn test_focused_node_creates_cursor() {
2298 let _app_context = crate::render_state::app_context_test_scope();
2299 with_test_runtime(|| {
2300 let state = TextFieldState::new("Test");
2301 let element = TextFieldElement::new(state, TextStyle::default());
2302 let node = element.create();
2303
2304 assert!(!node.is_focused());
2306
2307 *node.refs.is_focused.borrow_mut() = true;
2309 assert!(node.is_focused());
2310
2311 assert_eq!(node.text(), "Test");
2313
2314 assert_eq!(node.selection().start, 4);
2316 });
2317 }
2318}