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, PointerSource,
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 touch: bool,
40 pub node_origin: Point,
42 pub padding_left: f32,
43 pub padding_top: f32,
44 pub scroll_offset: f32,
45 pub line_height: f32,
46 pub wrap_width: Option<f32>,
49}
50
51#[derive(Clone)]
56pub struct TextFieldHandleController {
57 inner: Rc<TextFieldHandleControllerInner>,
58}
59
60impl PartialEq for TextFieldHandleController {
61 fn eq(&self, other: &Self) -> bool {
62 Rc::ptr_eq(&self.inner, &other.inner)
63 }
64}
65
66struct TextFieldHandleControllerInner {
67 metrics: Cell<Option<TextFieldHandleMetrics>>,
68 revision: MutableState<u64>,
69}
70
71impl TextFieldHandleController {
72 pub fn new() -> Self {
75 Self {
76 inner: Rc::new(TextFieldHandleControllerInner {
77 metrics: Cell::new(None),
78 revision: mutableStateOf(0u64),
79 }),
80 }
81 }
82
83 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
86 if self.inner.metrics.get() != Some(metrics) {
87 self.inner.metrics.set(Some(metrics));
88 self.inner
89 .revision
90 .update(|value| *value = value.wrapping_add(1));
91 }
92 }
93
94 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
97 let _ = self.inner.revision.value();
98 self.inner.metrics.get()
99 }
100}
101
102impl Default for TextFieldHandleController {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
110
111const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
113
114const DEFAULT_LINE_HEIGHT: f32 = 20.0;
116
117const CURSOR_WIDTH: f32 = 2.0;
119
120pub(crate) fn compute_horizontal_scroll_offset(
131 current_offset: f32,
132 cursor_x: f32,
133 text_width: f32,
134 viewport_width: f32,
135) -> f32 {
136 if viewport_width <= 0.0 {
137 return 0.0;
138 }
139 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
140 let mut offset = current_offset.clamp(0.0, max_offset);
141 let visible_end = offset + viewport_width - CURSOR_WIDTH;
142 if cursor_x > visible_end {
143 offset = cursor_x - viewport_width + CURSOR_WIDTH;
145 } else if cursor_x < offset {
146 offset = cursor_x;
148 }
149 offset.clamp(0.0, max_offset)
150}
151
152pub(crate) fn intersect_rect(
157 rect: cranpose_ui_graphics::Rect,
158 bounds: cranpose_ui_graphics::Rect,
159) -> Option<cranpose_ui_graphics::Rect> {
160 let x0 = rect.x.max(bounds.x);
161 let y0 = rect.y.max(bounds.y);
162 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
163 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
164 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
165 x: x0,
166 y: y0,
167 width: x1 - x0,
168 height: y1 - y0,
169 })
170}
171
172pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
175
176pub(crate) fn caret_visual_line_for_offset(
186 text: &str,
187 style: &TextStyle,
188 node_id: Option<cranpose_core::NodeId>,
189 wrap_width: Option<f32>,
190 offset: usize,
191) -> (usize, usize) {
192 let offset = offset.min(text.len());
193 match wrap_width {
194 Some(width) if width.is_finite() && width > 0.0 => {
195 let annotated = crate::text::AnnotatedString::from(text);
196 let ranges = crate::text::wrapped_line_ranges(
197 node_id,
198 &annotated,
199 style,
200 crate::text::TextLayoutOptions::default(),
201 Some(width),
202 );
203 crate::text_selection::caret_visual_line(&ranges, offset)
204 }
205 _ => {
206 let before = &text[..offset];
207 let line_index = before.matches('\n').count();
208 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
209 (line_index, line_start)
210 }
211 }
212}
213
214#[allow(clippy::too_many_arguments)]
225pub(crate) fn range_visual_line_rects(
226 text: &str,
227 style: &TextStyle,
228 node_id: Option<cranpose_core::NodeId>,
229 wrap_width: Option<f32>,
230 padding_left: f32,
231 padding_top: f32,
232 pan: f32,
233 line_height: f32,
234 start: usize,
235 end: usize,
236) -> Vec<cranpose_ui_graphics::Rect> {
237 if start >= end {
238 return Vec::new();
239 }
240 let annotated = crate::text::AnnotatedString::from(text);
241 let line_ranges = crate::text::wrapped_line_ranges(
242 node_id,
243 &annotated,
244 style,
245 crate::text::TextLayoutOptions::default(),
246 wrap_width,
247 );
248 let mut rects = Vec::new();
249 for (line_idx, line_range) in line_ranges.iter().enumerate() {
250 let line_start = line_range.start;
251 let line_end = line_range.end;
252 if end <= line_start || start >= line_end {
253 continue;
254 }
255 let seg_start = start.max(line_start);
256 let seg_end = end.min(line_end);
257 let x0 = crate::text::measure_text(
258 &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
259 style,
260 )
261 .width
262 + padding_left
263 - pan;
264 let x1 = crate::text::measure_text(
265 &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
266 style,
267 )
268 .width
269 + padding_left
270 - pan;
271 let width = x1 - x0;
272 if width > 0.0 {
273 rects.push(cranpose_ui_graphics::Rect {
274 x: x0,
275 y: padding_top + line_idx as f32 * line_height,
276 width,
277 height: line_height,
278 });
279 }
280 }
281 rects
282}
283
284#[derive(Clone)]
290pub(crate) struct TextFieldRefs {
291 pub is_focused: Rc<RefCell<bool>>,
293 pub content_offset: Rc<Cell<f32>>,
295 pub content_y_offset: Rc<Cell<f32>>,
297 pub drag_anchor: Rc<Cell<Option<usize>>>,
299 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
301 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
303 pub click_count: Rc<Cell<u8>>,
305 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
307 pub scroll_offset: Rc<Cell<f32>>,
310 pub last_pointer_source: Rc<Cell<PointerSource>>,
314 pub node_origin: Rc<Cell<Point>>,
318 pub line_height: Rc<Cell<f32>>,
322 pub wrap_width: Rc<Cell<Option<f32>>>,
326}
327
328impl TextFieldRefs {
329 pub fn new() -> Self {
331 Self {
332 is_focused: Rc::new(RefCell::new(false)),
333 content_offset: Rc::new(Cell::new(0.0_f32)),
334 content_y_offset: Rc::new(Cell::new(0.0_f32)),
335 drag_anchor: Rc::new(Cell::new(None::<usize>)),
336 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
337 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
338 click_count: Rc::new(Cell::new(0_u8)),
339 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
340 scroll_offset: Rc::new(Cell::new(0.0_f32)),
341 last_pointer_source: Rc::new(Cell::new(PointerSource::Unknown)),
342 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
343 line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
344 wrap_width: Rc::new(Cell::new(None::<f32>)),
345 }
346 }
347}
348
349use crate::text::TextStyle; pub struct TextFieldModifierNode {
358 state: TextFieldState,
360 refs: TextFieldRefs,
362 style: TextStyle, cursor_brush: Brush,
366 selection_brush: Brush,
368 line_limits: TextFieldLineLimits,
370 cached_text: String,
372 cached_selection: TextRange,
374 node_state: NodeState,
376 measured_size: Rc<Cell<Size>>,
378 measured_line_height: Rc<Cell<f32>>,
380 measured_wrap_width: Rc<Cell<Option<f32>>>,
386 cached_handler: Rc<dyn Fn(PointerEvent)>,
388 cached_pan_resolver: TextPanResolver,
390 handle_controller: Option<TextFieldHandleController>,
394}
395
396impl std::fmt::Debug for TextFieldModifierNode {
397 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398 f.debug_struct("TextFieldModifierNode")
399 .field("text", &self.state.text())
400 .field("style", &self.style)
401 .field("is_focused", &*self.refs.is_focused.borrow())
402 .finish()
403 }
404}
405
406use crate::text_field_handler::TextFieldHandler;
408
409impl TextFieldModifierNode {
410 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
412 let value = state.value();
413 let refs = TextFieldRefs::new();
414 let refs_line_height = refs.line_height.clone();
415 let refs_wrap_width = refs.wrap_width.clone();
416 let line_limits = TextFieldLineLimits::default();
417 let cached_handler =
418 Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
419 let cached_pan_resolver =
420 Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
421
422 Self {
423 state,
424 refs,
425 style,
426 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
427 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
428 line_limits,
429 cached_text: value.text,
430 cached_selection: value.selection,
431 node_state: NodeState::new(),
432 measured_size: Rc::new(Cell::new(Size {
433 width: 0.0,
434 height: 0.0,
435 })),
436 measured_line_height: refs_line_height,
440 measured_wrap_width: refs_wrap_width,
441 cached_handler,
442 cached_pan_resolver,
443 handle_controller: None,
444 }
445 }
446
447 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
449 self.line_limits = line_limits;
450 self.cached_pan_resolver = Self::create_pan_resolver(
451 self.state.clone(),
452 self.refs.clone(),
453 line_limits,
454 self.style.clone(),
455 );
456 self
457 }
458
459 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
461 self.handle_controller = Some(controller);
462 self
463 }
464
465 fn create_pan_resolver(
473 state: TextFieldState,
474 refs: TextFieldRefs,
475 line_limits: TextFieldLineLimits,
476 style: TextStyle,
477 ) -> TextPanResolver {
478 Rc::new(move |viewport_width: f32| {
479 if !line_limits.is_single_line() {
480 refs.scroll_offset.set(0.0);
482 return 0.0;
483 }
484 let text = state.text();
485 let pos = state.selection().start.min(text.len());
486 let text_width = crate::text::measure_text(
487 &crate::text::AnnotatedString::from(text.as_str()),
488 &style,
489 )
490 .width;
491 let cursor_x = crate::text::measure_text(
492 &crate::text::AnnotatedString::from(&text[..pos]),
493 &style,
494 )
495 .width;
496 let offset = compute_horizontal_scroll_offset(
497 refs.scroll_offset.get(),
498 cursor_x,
499 text_width,
500 viewport_width,
501 );
502 refs.scroll_offset.set(offset);
503 offset
504 })
505 }
506
507 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
512 self.line_limits
513 .is_single_line()
514 .then(|| self.cached_pan_resolver.clone())
515 }
516
517 pub fn scroll_offset(&self) -> f32 {
519 self.refs.scroll_offset.get()
520 }
521
522 pub fn line_limits(&self) -> TextFieldLineLimits {
524 self.line_limits
525 }
526
527 fn create_handler(
529 state: TextFieldState,
530 refs: TextFieldRefs,
531 line_limits: TextFieldLineLimits,
532 style: TextStyle, ) -> Rc<dyn Fn(PointerEvent)> {
534 use crate::text_selection::{
537 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
538 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
539 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
540 };
541 use crate::word_boundaries::find_word_boundaries;
542
543 Rc::new(move |event: PointerEvent| {
544 refs.node_origin.set(Point {
551 x: event.global_position.x - event.position.x,
552 y: event.global_position.y - event.position.y,
553 });
554
555 let click_x =
559 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
560 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
561
562 match event.kind {
563 PointerEventKind::Down => {
564 refs.last_pointer_source.set(event.source);
568
569 let handler = TextFieldHandler::new(
574 state.clone(),
575 refs.node_id.get(),
576 line_limits,
577 crate::text_field_handler::CaretGeometryRefs {
578 node_origin: refs.node_origin.clone(),
579 content_offset: refs.content_offset.clone(),
580 content_y_offset: refs.content_y_offset.clone(),
581 scroll_offset: refs.scroll_offset.clone(),
582 style: style.clone(),
583 },
584 );
585 crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
586
587 let now = web_time::Instant::now();
588 let text = state.text();
589 let pos = crate::text::offset_for_position_wrapped(
590 &text,
591 &style,
592 refs.node_id.get(),
593 refs.wrap_width.get(),
594 refs.line_height.get(),
595 click_x,
596 click_y,
597 );
598
599 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
604 let count = refs.click_count.get();
605 (count > 0).then_some((count, px, py))
606 });
607 let elapsed_ms = refs
608 .last_click_time
609 .get()
610 .map(|last| now.duration_since(last).as_millis())
611 .unwrap_or(u128::MAX);
612 let tap_count = classify_tap_count(
613 previous,
614 elapsed_ms,
615 event.position.x,
616 event.position.y,
617 MULTI_TAP_TIMEOUT_MS,
618 MULTI_TAP_SLOP_PX,
619 );
620
621 let selection = state.selection();
629 let tap_in_selection =
630 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
631 let repeat_in_place = refs
634 .last_click_pos
635 .get()
636 .map(|(px, py)| {
637 let dx = event.position.x - px;
638 let dy = event.position.y - py;
639 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
640 })
641 .unwrap_or(false);
642 let effective_count = resolve_selection_tap_count(
643 tap_count,
644 refs.click_count.get(),
645 tap_in_selection,
646 repeat_in_place,
647 );
648
649 match tap_selection_granularity(effective_count) {
650 SelectionGranularity::Paragraph => {
651 let (start, end) = find_paragraph_boundaries(&text, pos);
653 state.edit(|buffer| {
654 buffer.select(TextRange::new(start, end));
655 });
656 refs.drag_anchor.set(Some(start));
657 }
658 SelectionGranularity::Line => {
659 let (line_start, line_end) = find_line_boundaries(&text, pos);
661 state.edit(|buffer| {
662 buffer.select(TextRange::new(line_start, line_end));
663 });
664 refs.drag_anchor.set(Some(line_start));
665 }
666 SelectionGranularity::Word => {
667 let (word_start, word_end) = find_word_boundaries(&text, pos);
670 state.edit(|buffer| {
671 buffer.select(TextRange::new(word_start, word_end));
672 });
673 refs.drag_anchor.set(Some(word_start));
674 }
675 SelectionGranularity::Caret => {
676 refs.drag_anchor.set(Some(pos));
678 state.edit(|buffer| {
679 buffer.place_cursor_before_char(pos);
680 });
681 }
682 }
683
684 refs.click_count.set(effective_count);
685 refs.last_click_time.set(Some(now));
686 refs.last_click_pos
687 .set(Some((event.position.x, event.position.y)));
688 event.consume();
689 }
690 PointerEventKind::Move => {
691 if let Some(anchor) = refs.drag_anchor.get() {
693 if *refs.is_focused.borrow() {
694 let text = state.text();
695 let current_pos = crate::text::offset_for_position_wrapped(
696 &text,
697 &style,
698 refs.node_id.get(),
699 refs.wrap_width.get(),
700 refs.line_height.get(),
701 click_x,
702 click_y,
703 );
704
705 state.set_selection(TextRange::new(anchor, current_pos));
707
708 crate::request_render_invalidation();
710
711 event.consume();
712 }
713 }
714 }
715 PointerEventKind::Up => {
716 refs.drag_anchor.set(None);
718 }
719 _ => {}
720 }
721 })
722 }
723
724 pub fn with_cursor_color(mut self, color: Color) -> Self {
726 self.cursor_brush = Brush::solid(color);
727 self
728 }
729
730 pub fn set_focused(&mut self, focused: bool) {
732 let current = *self.refs.is_focused.borrow();
733 if current != focused {
734 *self.refs.is_focused.borrow_mut() = focused;
735 }
736 }
737
738 pub fn is_focused(&self) -> bool {
740 *self.refs.is_focused.borrow()
741 }
742
743 pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
745 self.refs.is_focused.clone()
746 }
747
748 pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
750 self.refs.content_offset.clone()
751 }
752
753 pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
755 self.refs.content_y_offset.clone()
756 }
757
758 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
771 self.refs.node_origin.clone()
772 }
773
774 pub fn text(&self) -> String {
776 self.state.text()
777 }
778
779 pub fn style(&self) -> &TextStyle {
780 &self.style
781 }
782
783 pub fn selection(&self) -> TextRange {
785 self.state.selection()
786 }
787
788 pub fn cursor_brush(&self) -> Brush {
790 self.cursor_brush.clone()
791 }
792
793 pub fn selection_brush(&self) -> Brush {
795 self.selection_brush.clone()
796 }
797
798 pub fn insert_text(&mut self, text: &str) {
800 self.state.edit(|buffer| {
801 buffer.insert(text);
802 });
803 }
804
805 pub fn copy_selection(&self) -> Option<String> {
808 self.state.copy_selection()
809 }
810
811 pub fn cut_selection(&mut self) -> Option<String> {
814 let text = self.copy_selection();
815 if text.is_some() {
816 self.state.edit(|buffer| {
817 buffer.delete(buffer.selection());
818 });
819 }
820 text
821 }
822
823 pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
826 self.state.clone()
827 }
828
829 pub fn set_content_offset(&self, offset: f32) {
832 self.refs.content_offset.set(offset);
833 }
834
835 pub fn set_content_y_offset(&self, offset: f32) {
838 self.refs.content_y_offset.set(offset);
839 }
840
841 fn wrap_width(&self, available_width: f32) -> Option<f32> {
848 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
849 .then_some(available_width)
850 }
851
852 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
858 let text = self.state.text();
859 let node_id = self.refs.node_id.get();
860 let annotated = crate::text::AnnotatedString::from(text.as_str());
861 let metrics = match wrap_width {
862 Some(max_width) => crate::text::measure_text_with_options_for_node(
863 node_id,
864 &annotated,
865 &self.style,
866 crate::text::TextLayoutOptions::default(),
867 Some(max_width),
868 ),
869 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
870 };
871 self.measured_line_height.set(metrics.line_height);
872 Size {
873 width: metrics.width,
874 height: metrics.height,
875 }
876 }
877
878 fn update_cached_state(&mut self) -> bool {
880 let value = self.state.value();
881 let text_changed = value.text != self.cached_text;
882 let selection_changed = value.selection != self.cached_selection;
883
884 if text_changed {
885 self.cached_text = value.text;
886 }
887 if selection_changed {
888 self.cached_selection = value.selection;
889 }
890
891 text_changed || selection_changed
892 }
893
894 pub fn position_cursor_at_offset(&self, x_offset: f32) {
897 let text = self.state.text();
898 if text.is_empty() {
899 self.state.edit(|buffer| {
900 buffer.place_cursor_at_start();
901 });
902 return;
903 }
904
905 let byte_offset = crate::text::get_offset_for_position(
908 &crate::text::AnnotatedString::from(text.as_str()),
909 &self.style,
910 x_offset + self.refs.scroll_offset.get(),
911 0.0,
912 );
913
914 self.state.edit(|buffer| {
915 buffer.place_cursor_before_char(byte_offset);
916 });
917 }
918
919 }
923
924impl DelegatableNode for TextFieldModifierNode {
925 fn node_state(&self) -> &NodeState {
926 &self.node_state
927 }
928}
929
930impl ModifierNode for TextFieldModifierNode {
931 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
932 self.refs.node_id.set(context.node_id());
934
935 context.invalidate(InvalidationKind::Layout);
936 context.invalidate(InvalidationKind::Draw);
937 context.invalidate(InvalidationKind::Semantics);
938 }
939
940 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
941 Some(self)
942 }
943
944 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
945 Some(self)
946 }
947
948 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
949 Some(self)
950 }
951
952 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
953 Some(self)
954 }
955
956 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
957 Some(self)
958 }
959
960 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
961 Some(self)
962 }
963
964 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
965 Some(self)
966 }
967
968 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
969 Some(self)
970 }
971}
972
973impl LayoutModifierNode for TextFieldModifierNode {
974 fn measure(
975 &self,
976 _context: &mut dyn ModifierNodeContext,
977 _measurable: &dyn Measurable,
978 constraints: Constraints,
979 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
980 let wrap_width = self.wrap_width(constraints.max_width);
984 self.measured_wrap_width.set(wrap_width);
987 let text_size = self.measure_text_content(wrap_width);
988
989 let min_height = if text_size.height < 1.0 {
991 DEFAULT_LINE_HEIGHT
992 } else {
993 text_size.height
994 };
995
996 let width = text_size
998 .width
999 .max(constraints.min_width)
1000 .min(constraints.max_width);
1001 let height = min_height
1002 .max(constraints.min_height)
1003 .min(constraints.max_height);
1004
1005 let size = Size { width, height };
1006 self.measured_size.set(size);
1007
1008 let _ = (self.cached_pan_resolver)(size.width);
1011
1012 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
1013 }
1014
1015 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1016 self.measure_text_content(None).width
1017 }
1018
1019 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1020 self.measure_text_content(None).width
1021 }
1022
1023 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1024 self.measure_text_content(self.wrap_width(width))
1025 .height
1026 .max(DEFAULT_LINE_HEIGHT)
1027 }
1028
1029 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1030 self.measure_text_content(self.wrap_width(width))
1031 .height
1032 .max(DEFAULT_LINE_HEIGHT)
1033 }
1034}
1035
1036impl DrawModifierNode for TextFieldModifierNode {
1037 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
1038 }
1042
1043 fn create_draw_closure(
1044 &self,
1045 ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
1046 {
1047 use cranpose_ui_graphics::DrawPrimitive;
1048
1049 let is_focused = self.refs.is_focused.clone();
1051 let state = self.state.clone();
1052 let content_offset = self.refs.content_offset.clone();
1053 let content_y_offset = self.refs.content_y_offset.clone();
1054 let cursor_brush = self.cursor_brush.clone();
1055 let selection_brush = self.selection_brush.clone();
1056 let style = self.style.clone();
1057 let cached_line_height = self.measured_line_height.clone();
1058 let measured_size = self.measured_size.clone();
1059 let measured_wrap_width = self.measured_wrap_width.clone();
1060 let node_id = self.refs.node_id.clone();
1061 let pan_resolver = self.cached_pan_resolver.clone();
1062 let handle_controller = self.handle_controller.clone();
1063 let node_origin = self.refs.node_origin.clone();
1064 let last_pointer_source = self.refs.last_pointer_source.clone();
1065
1066 Some(Rc::new(move |size| {
1067 if !*is_focused.borrow() {
1069 if let Some(controller) = &handle_controller {
1072 controller.publish(TextFieldHandleMetrics {
1073 focused: false,
1074 touch: false,
1075 node_origin: node_origin.get(),
1076 padding_left: 0.0,
1077 padding_top: 0.0,
1078 scroll_offset: 0.0,
1079 line_height: cached_line_height.get(),
1080 wrap_width: measured_wrap_width.get(),
1081 });
1082 }
1083 return vec![];
1084 }
1085
1086 let mut primitives = Vec::new();
1087
1088 let text = state.text();
1089 let selection = state.selection();
1090 let padding_left = content_offset.get();
1091 let padding_top = content_y_offset.get();
1092 let line_height = cached_line_height.get();
1095
1096 let measured = measured_size.get();
1099 let viewport_width = if measured.width > 0.0 {
1100 measured.width
1101 } else {
1102 (size.width - padding_left).max(0.0)
1103 };
1104 let viewport_height = if measured.height > 0.0 {
1105 measured.height
1106 } else {
1107 (size.height - padding_top).max(0.0)
1108 };
1109 let pan = pan_resolver(viewport_width);
1111
1112 if let Some(controller) = &handle_controller {
1115 controller.publish(TextFieldHandleMetrics {
1116 focused: true,
1117 touch: last_pointer_source.get().is_touch_like(),
1118 node_origin: node_origin.get(),
1119 padding_left,
1120 padding_top,
1121 scroll_offset: pan,
1122 line_height,
1123 wrap_width: measured_wrap_width.get(),
1124 });
1125 }
1126 let clip_bounds = cranpose_ui_graphics::Rect {
1130 x: padding_left,
1131 y: padding_top,
1132 width: viewport_width,
1133 height: viewport_height,
1134 };
1135
1136 if !selection.collapsed() {
1138 let sel_start = selection.min();
1139 let sel_end = selection.max();
1140
1141 for sel_rect in range_visual_line_rects(
1144 &text,
1145 &style,
1146 node_id.get(),
1147 measured_wrap_width.get(),
1148 padding_left,
1149 padding_top,
1150 pan,
1151 line_height,
1152 sel_start,
1153 sel_end,
1154 ) {
1155 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1156 primitives.push(DrawPrimitive::Rect {
1157 rect: clipped,
1158 brush: selection_brush.clone(),
1159 });
1160 }
1161 }
1162 }
1163
1164 if let Some(comp_range) = state.composition() {
1167 let comp_start = comp_range.min();
1168 let comp_end = comp_range.max();
1169
1170 if comp_start < comp_end && comp_end <= text.len() {
1171 let underline_brush = cranpose_ui_graphics::Brush::solid(
1173 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1174 );
1175 let underline_height: f32 = 2.0;
1176
1177 for line_rect in range_visual_line_rects(
1180 &text,
1181 &style,
1182 node_id.get(),
1183 measured_wrap_width.get(),
1184 padding_left,
1185 padding_top,
1186 pan,
1187 line_height,
1188 comp_start,
1189 comp_end,
1190 ) {
1191 let underline_rect = cranpose_ui_graphics::Rect {
1192 x: line_rect.x,
1193 y: line_rect.y + line_height - underline_height,
1194 width: line_rect.width,
1195 height: underline_height,
1196 };
1197 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1198 primitives.push(DrawPrimitive::Rect {
1199 rect: clipped,
1200 brush: underline_brush.clone(),
1201 });
1202 }
1203 }
1204 }
1205 }
1206
1207 if crate::cursor_animation::is_cursor_visible() {
1209 let pos = selection.start.min(text.len());
1210 let (line_index, line_start) = caret_visual_line_for_offset(
1216 &text,
1217 &style,
1218 node_id.get(),
1219 measured_wrap_width.get(),
1220 pos,
1221 );
1222 let cursor_x = crate::text::measure_text(
1223 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1224 &style,
1225 )
1226 .width
1227 + padding_left
1228 - pan;
1229 let cursor_y = padding_top + line_index as f32 * line_height;
1230
1231 let cursor_rect = cranpose_ui_graphics::Rect {
1232 x: cursor_x,
1233 y: cursor_y,
1234 width: CURSOR_WIDTH,
1235 height: line_height,
1236 };
1237
1238 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1239 primitives.push(DrawPrimitive::Rect {
1240 rect: clipped,
1241 brush: cursor_brush.clone(),
1242 });
1243 }
1244 }
1245
1246 primitives
1247 }))
1248 }
1249}
1250
1251impl SemanticsNode for TextFieldModifierNode {
1252 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1253 let text = self.state.text();
1254 config.content_description = Some(text);
1255 config.is_editable_text = true;
1256 config.text_selection = Some(self.state.selection());
1257 }
1258}
1259
1260impl PointerInputNode for TextFieldModifierNode {
1261 fn on_pointer_event(
1262 &mut self,
1263 _context: &mut dyn ModifierNodeContext,
1264 _event: &PointerEvent,
1265 ) -> bool {
1266 false
1277 }
1278
1279 fn hit_test(&self, x: f32, y: f32) -> bool {
1280 let size = self.measured_size.get();
1282 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1283 }
1284
1285 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1286 Some(self.cached_handler.clone())
1288 }
1289}
1290
1291#[derive(Clone)]
1302pub struct TextFieldElement {
1303 state: TextFieldState,
1305 style: TextStyle,
1307 cursor_color: Color,
1309 line_limits: TextFieldLineLimits,
1311 handle_controller: Option<TextFieldHandleController>,
1314}
1315
1316impl TextFieldElement {
1317 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1319 Self {
1320 state,
1321 style,
1322 cursor_color: DEFAULT_CURSOR_COLOR,
1323 line_limits: TextFieldLineLimits::default(),
1324 handle_controller: None,
1325 }
1326 }
1327
1328 pub fn with_cursor_color(mut self, color: Color) -> Self {
1330 self.cursor_color = color;
1331 self
1332 }
1333
1334 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1336 self.line_limits = line_limits;
1337 self
1338 }
1339
1340 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1342 self.handle_controller = Some(controller);
1343 self
1344 }
1345}
1346
1347impl std::fmt::Debug for TextFieldElement {
1348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1349 f.debug_struct("TextFieldElement")
1350 .field("text", &self.state.text())
1351 .field("style", &self.style)
1352 .field("cursor_color", &self.cursor_color)
1353 .finish()
1354 }
1355}
1356
1357impl Hash for TextFieldElement {
1358 fn hash<H: Hasher>(&self, state: &mut H) {
1359 std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1362 self.cursor_color.0.to_bits().hash(state);
1364 self.cursor_color.1.to_bits().hash(state);
1365 self.cursor_color.2.to_bits().hash(state);
1366 self.cursor_color.3.to_bits().hash(state);
1367 self.style.render_hash().hash(state);
1368 self.line_limits.hash(state);
1369 }
1370}
1371
1372impl PartialEq for TextFieldElement {
1373 fn eq(&self, other: &Self) -> bool {
1374 self.state == other.state
1378 && self.style == other.style
1379 && self.cursor_color == other.cursor_color
1380 && self.line_limits == other.line_limits
1381 }
1382}
1383
1384impl Eq for TextFieldElement {}
1385
1386impl ModifierNodeElement for TextFieldElement {
1387 type Node = TextFieldModifierNode;
1388
1389 fn create(&self) -> Self::Node {
1390 let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1391 .with_cursor_color(self.cursor_color)
1392 .with_line_limits(self.line_limits);
1393 if let Some(controller) = self.handle_controller.clone() {
1394 node = node.with_handle_controller(controller);
1395 }
1396 node
1397 }
1398
1399 fn update(&self, node: &mut Self::Node) {
1400 node.state = self.state.clone();
1402 node.style = self.style.clone();
1403 node.cursor_brush = Brush::solid(self.cursor_color);
1404 node.line_limits = self.line_limits;
1405 node.handle_controller = self.handle_controller.clone();
1406
1407 node.cached_handler = TextFieldModifierNode::create_handler(
1409 node.state.clone(),
1410 node.refs.clone(),
1411 node.line_limits,
1412 self.style.clone(),
1413 );
1414
1415 node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1417 node.state.clone(),
1418 node.refs.clone(),
1419 node.line_limits,
1420 self.style.clone(),
1421 );
1422
1423 if node.update_cached_state() {
1425 }
1428 }
1429
1430 fn capabilities(&self) -> NodeCapabilities {
1431 NodeCapabilities::LAYOUT
1432 | NodeCapabilities::DRAW
1433 | NodeCapabilities::SEMANTICS
1434 | NodeCapabilities::POINTER_INPUT
1435 }
1436
1437 fn always_update(&self) -> bool {
1438 true
1440 }
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445 use super::*;
1446 use crate::text::TextStyle;
1447 use cranpose_core::{DefaultScheduler, Runtime};
1448 use std::sync::Arc;
1449
1450 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1452 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1453 f()
1454 }
1455
1456 #[test]
1457 fn text_field_node_creation() {
1458 let _app_context = crate::render_state::app_context_test_scope();
1459 with_test_runtime(|| {
1460 let state = TextFieldState::new("Hello");
1461 let node = TextFieldModifierNode::new(state, TextStyle::default());
1462 assert_eq!(node.text(), "Hello");
1463 assert!(!node.is_focused());
1464 });
1465 }
1466
1467 #[test]
1472 fn selection_rects_follow_wrapped_visual_lines() {
1473 let _app_context = crate::render_state::app_context_test_scope();
1474 let text = "aaaaa\nbb";
1477 let style = TextStyle::default();
1478 let line_height = 10.0_f32;
1479
1480 let rects = range_visual_line_rects(
1483 text,
1484 &style,
1485 None,
1486 Some(30.0),
1487 0.0,
1488 0.0,
1489 0.0,
1490 line_height,
1491 6,
1492 8,
1493 );
1494 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1495 assert_eq!(
1496 rects[0].y,
1497 2.0 * line_height,
1498 "highlight must land on visual line 2, not logical line 1"
1499 );
1500 assert!(rects[0].width > 0.0);
1501
1502 let spanning = range_visual_line_rects(
1505 text,
1506 &style,
1507 None,
1508 Some(30.0),
1509 0.0,
1510 0.0,
1511 0.0,
1512 line_height,
1513 0,
1514 5,
1515 );
1516 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1517 assert_eq!(spanning[0].y, 0.0);
1518 assert_eq!(spanning[1].y, line_height);
1519 }
1520
1521 #[test]
1526 fn tap_resolves_offset_on_wrapped_visual_line() {
1527 let _app_context = crate::render_state::app_context_test_scope();
1528 let text = "aaaaa\nbb";
1531 let style = TextStyle::default();
1532 let line_height = 10.0_f32;
1533
1534 let off = crate::text::offset_for_position_wrapped(
1537 text,
1538 &style,
1539 None,
1540 Some(30.0),
1541 line_height,
1542 8.0,
1543 22.0,
1544 );
1545 assert!(
1546 (6..=8).contains(&off),
1547 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1548 );
1549
1550 let off1 = crate::text::offset_for_position_wrapped(
1553 text,
1554 &style,
1555 None,
1556 Some(30.0),
1557 line_height,
1558 4.0,
1559 12.0,
1560 );
1561 assert!(
1562 (3..=5).contains(&off1),
1563 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1564 );
1565
1566 let off2 = crate::text::offset_for_position_wrapped(
1568 "hello",
1569 &style,
1570 None,
1571 None,
1572 line_height,
1573 0.0,
1574 0.0,
1575 );
1576 assert_eq!(off2, 0);
1577 }
1578
1579 #[test]
1580 fn text_field_node_focus() {
1581 let _app_context = crate::render_state::app_context_test_scope();
1582 with_test_runtime(|| {
1583 let state = TextFieldState::new("Test");
1584 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1585 assert!(!node.is_focused());
1586
1587 node.set_focused(true);
1588 assert!(node.is_focused());
1589
1590 node.set_focused(false);
1591 assert!(!node.is_focused());
1592 });
1593 }
1594
1595 #[test]
1596 fn text_field_element_creates_node() {
1597 let _app_context = crate::render_state::app_context_test_scope();
1598 with_test_runtime(|| {
1599 let state = TextFieldState::new("Hello World");
1600 let element = TextFieldElement::new(state, TextStyle::default());
1601
1602 let node = element.create();
1603 assert_eq!(node.text(), "Hello World");
1604 });
1605 }
1606
1607 #[test]
1616 fn touch_press_publishes_touch_handle_metrics_but_mouse_does_not() {
1617 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1618 use cranpose_ui_graphics::Point;
1619
1620 let _app_context = crate::render_state::app_context_test_scope();
1621 with_test_runtime(|| {
1622 let state = TextFieldState::new("hello world");
1623 let controller = TextFieldHandleController::new();
1624 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1625 .with_handle_controller(controller.clone());
1626 node.measured_size.set(Size {
1628 width: 120.0,
1629 height: 20.0,
1630 });
1631
1632 let handler = node
1633 .pointer_input_handler()
1634 .expect("field exposes a pointer handler");
1635 let draw = node
1636 .create_draw_closure()
1637 .expect("field exposes a draw closure");
1638 let at = Point { x: 12.0, y: 8.0 };
1639 let size = Size {
1640 width: 120.0,
1641 height: 20.0,
1642 };
1643
1644 handler(
1647 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1648 );
1649 let _ = draw(size);
1650 let metrics = controller
1651 .metrics()
1652 .expect("focused field publishes handle metrics");
1653 assert!(metrics.focused, "a tap focuses the field");
1654 assert!(
1655 metrics.touch,
1656 "a touch tap must publish touch = true so the finger handles show"
1657 );
1658
1659 handler(
1662 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1663 );
1664 let _ = draw(size);
1665 let metrics = controller
1666 .metrics()
1667 .expect("focused field publishes handle metrics");
1668 assert!(
1669 !metrics.touch,
1670 "a mouse tap must publish touch = false (clean caret, no finger handle)"
1671 );
1672
1673 crate::text_field_focus::clear_focus();
1674 });
1675 }
1676
1677 #[test]
1685 fn double_tap_selects_the_word_under_the_finger() {
1686 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1687 use cranpose_ui_graphics::Point;
1688
1689 let _app_context = crate::render_state::app_context_test_scope();
1690 with_test_runtime(|| {
1691 let state = TextFieldState::new("hello world");
1692 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1693 node.measured_size.set(Size {
1694 width: 200.0,
1695 height: 20.0,
1696 });
1697 let handler = node
1698 .pointer_input_handler()
1699 .expect("field exposes a pointer handler");
1700
1701 let at = Point { x: 2.0, y: 8.0 };
1704 handler(
1705 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1706 );
1707 handler(
1708 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1709 );
1710
1711 let selection = state.selection();
1712 assert!(
1713 !selection.collapsed(),
1714 "a double tap must produce a (word) selection, got {selection:?}"
1715 );
1716 let selected = &state.text()[selection.min()..selection.max()];
1717 assert_eq!(
1718 selected, "hello",
1719 "double tap should select the whole word under the finger"
1720 );
1721
1722 crate::text_field_focus::clear_focus();
1723 });
1724 }
1725
1726 #[test]
1730 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1731 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1732 use cranpose_ui_graphics::Point;
1733
1734 let _app_context = crate::render_state::app_context_test_scope();
1735 with_test_runtime(|| {
1736 let text = "alpha beta\ngamma delta\n\nsecond para";
1739 let state = TextFieldState::new(text);
1740 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1741 .with_line_limits(TextFieldLineLimits::MultiLine {
1742 min_lines: 1,
1743 max_lines: usize::MAX,
1744 });
1745 node.measured_size.set(Size {
1746 width: 400.0,
1747 height: 80.0,
1748 });
1749 let handler = node
1750 .pointer_input_handler()
1751 .expect("field exposes a pointer handler");
1752
1753 let at = Point { x: 2.0, y: 4.0 };
1755 let tap = || {
1756 handler(
1757 PointerEvent::new(PointerEventKind::Down, at, at)
1758 .with_source(PointerSource::Touch),
1759 );
1760 };
1761 let selected = |state: &TextFieldState| {
1762 let s = state.selection();
1763 state.text()[s.min()..s.max()].to_string()
1764 };
1765
1766 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1768 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1770 tap(); assert_eq!(
1772 selected(&state),
1773 "alpha beta",
1774 "triple tap selects the line"
1775 );
1776 tap(); assert_eq!(
1778 selected(&state),
1779 "alpha beta\ngamma delta",
1780 "fourth tap grows to the paragraph"
1781 );
1782 tap(); assert_eq!(
1784 selected(&state),
1785 "alpha",
1786 "fifth tap cycles back to the word"
1787 );
1788
1789 crate::text_field_focus::clear_focus();
1790 });
1791 }
1792
1793 #[test]
1797 fn single_tap_inside_selection_selects_the_word() {
1798 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1799 use cranpose_ui_graphics::Point;
1800
1801 let _app_context = crate::render_state::app_context_test_scope();
1802 with_test_runtime(|| {
1803 let state = TextFieldState::new("hello world");
1804 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1805 node.measured_size.set(Size {
1806 width: 200.0,
1807 height: 20.0,
1808 });
1809 let handler = node
1810 .pointer_input_handler()
1811 .expect("field exposes a pointer handler");
1812
1813 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1815 assert!(!state.selection().collapsed());
1816
1817 let at = Point { x: 2.0, y: 8.0 };
1820 handler(
1821 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1822 );
1823
1824 let selection = state.selection();
1825 assert!(
1826 !selection.collapsed(),
1827 "a tap inside a selection must not collapse it, got {selection:?}"
1828 );
1829 assert_eq!(
1830 &state.text()[selection.min()..selection.max()],
1831 "hello",
1832 "a tap inside a selection re-selects the word under the finger"
1833 );
1834
1835 crate::text_field_focus::clear_focus();
1836 });
1837 }
1838
1839 #[test]
1847 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
1848 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1849 use cranpose_ui_graphics::Point;
1850
1851 let _app_context = crate::render_state::app_context_test_scope();
1852 with_test_runtime(|| {
1853 let text = "alpha beta\ngamma delta\n\nsecond para";
1854 let state = TextFieldState::new(text);
1855 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1856 .with_line_limits(TextFieldLineLimits::MultiLine {
1857 min_lines: 1,
1858 max_lines: usize::MAX,
1859 });
1860 node.measured_size.set(Size {
1861 width: 400.0,
1862 height: 80.0,
1863 });
1864 let handler = node
1865 .pointer_input_handler()
1866 .expect("field exposes a pointer handler");
1867
1868 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
1870
1871 let at = Point { x: 2.0, y: 4.0 };
1872 let selected = |state: &TextFieldState| {
1873 let s = state.selection();
1874 state.text()[s.min()..s.max()].to_string()
1875 };
1876 let slow_tap = || {
1879 node.refs.last_click_time.set(None);
1880 handler(
1881 PointerEvent::new(PointerEventKind::Down, at, at)
1882 .with_source(PointerSource::Touch),
1883 );
1884 };
1885
1886 slow_tap(); assert_eq!(
1888 selected(&state),
1889 "alpha",
1890 "tap inside selection grabs the word"
1891 );
1892 slow_tap(); assert_eq!(
1894 selected(&state),
1895 "alpha beta",
1896 "same-spot tap grows to the line even after the timeout"
1897 );
1898 slow_tap(); assert_eq!(
1900 selected(&state),
1901 "alpha beta\ngamma delta",
1902 "same-spot tap grows to the paragraph"
1903 );
1904 slow_tap(); assert_eq!(
1906 selected(&state),
1907 "alpha",
1908 "same-spot tap cycles back to the word"
1909 );
1910
1911 crate::text_field_focus::clear_focus();
1912 });
1913 }
1914
1915 #[test]
1916 fn text_field_element_equality() {
1917 let _app_context = crate::render_state::app_context_test_scope();
1918 with_test_runtime(|| {
1919 let state1 = TextFieldState::new("Hello");
1920 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
1923 let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
1929 assert_ne!(elem1, elem3, "Different states should not be equal");
1930 });
1931 }
1932
1933 #[test]
1934 fn text_field_element_update_refreshes_existing_node_style() {
1935 let _app_context = crate::render_state::app_context_test_scope();
1936 with_test_runtime(|| {
1937 let state = TextFieldState::new("themed text");
1938 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1939 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1940 ..crate::text::SpanStyle::default()
1941 });
1942 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1943 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1944 ..crate::text::SpanStyle::default()
1945 });
1946 let initial = TextFieldElement::new(state.clone(), dark_style);
1947 let updated = TextFieldElement::new(state, light_style.clone());
1948 let mut node = initial.create();
1949
1950 updated.update(&mut node);
1951
1952 assert_eq!(node.text(), "themed text");
1953 assert_eq!(node.style(), &light_style);
1954 });
1955 }
1956
1957 #[test]
1962 fn multiline_field_measures_wrapped_height() {
1963 let _app_context = crate::render_state::app_context_test_scope();
1964 with_test_runtime(|| {
1965 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
1967 let node = TextFieldModifierNode::new(state, TextStyle::default());
1968 assert!(
1969 !node.line_limits().is_single_line(),
1970 "default fields are multi-line"
1971 );
1972
1973 let natural = node.measure_text_content(None);
1974 let wrapped = node.measure_text_content(node.wrap_width(20.0));
1975
1976 assert!(
1977 wrapped.height > natural.height,
1978 "wrapped multi-line height {} must exceed the single-line height {}",
1979 wrapped.height,
1980 natural.height
1981 );
1982 });
1983 }
1984
1985 #[test]
1988 fn single_line_field_never_wraps() {
1989 let _app_context = crate::render_state::app_context_test_scope();
1990 with_test_runtime(|| {
1991 let state = TextFieldState::new("abcd ".repeat(40));
1992 let node = TextFieldModifierNode::new(state, TextStyle::default())
1993 .with_line_limits(TextFieldLineLimits::SingleLine);
1994 assert_eq!(
1995 node.wrap_width(20.0),
1996 None,
1997 "single-line fields must not wrap"
1998 );
1999 });
2000 }
2001
2002 #[test]
2008 fn test_cursor_x_position_calculation() {
2009 let _app_context = crate::render_state::app_context_test_scope();
2010 with_test_runtime(|| {
2011 let style = crate::text::TextStyle::default();
2013
2014 let empty_width =
2016 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2017 assert!(
2018 empty_width.abs() < 0.1,
2019 "Empty text should have 0 width, got {}",
2020 empty_width
2021 );
2022
2023 let hi_width =
2025 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2026 assert!(
2027 hi_width > 0.0,
2028 "Text 'Hi' should have positive width: {}",
2029 hi_width
2030 );
2031
2032 let h_width =
2034 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2035 assert!(h_width > 0.0, "Text 'H' should have positive width");
2036 assert!(
2037 h_width < hi_width,
2038 "'H' width {} should be less than 'Hi' width {}",
2039 h_width,
2040 hi_width
2041 );
2042
2043 let state = TextFieldState::new("Hi");
2045 assert_eq!(
2046 state.selection().start,
2047 2,
2048 "Cursor should be at position 2 (end of 'Hi')"
2049 );
2050
2051 let text = state.text();
2053 let cursor_pos = state.selection().start;
2054 let text_before_cursor = &text[..cursor_pos.min(text.len())];
2055 assert_eq!(text_before_cursor, "Hi");
2056
2057 let cursor_x = crate::text::measure_text(
2059 &crate::text::AnnotatedString::from(text_before_cursor),
2060 &style,
2061 )
2062 .width;
2063 assert!(
2064 (cursor_x - hi_width).abs() < 0.1,
2065 "Cursor x {} should equal 'Hi' width {}",
2066 cursor_x,
2067 hi_width
2068 );
2069 });
2070 }
2071
2072 #[test]
2074 fn test_focused_node_creates_cursor() {
2075 let _app_context = crate::render_state::app_context_test_scope();
2076 with_test_runtime(|| {
2077 let state = TextFieldState::new("Test");
2078 let element = TextFieldElement::new(state.clone(), TextStyle::default());
2079 let node = element.create();
2080
2081 assert!(!node.is_focused());
2083
2084 *node.refs.is_focused.borrow_mut() = true;
2086 assert!(node.is_focused());
2087
2088 assert_eq!(node.text(), "Test");
2090
2091 assert_eq!(node.selection().start, 4);
2093 });
2094 }
2095}