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#[derive(Clone)]
220pub(crate) struct TextFieldRefs {
221 pub is_focused: Rc<RefCell<bool>>,
223 pub content_offset: Rc<Cell<f32>>,
225 pub content_y_offset: Rc<Cell<f32>>,
227 pub drag_anchor: Rc<Cell<Option<usize>>>,
229 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
231 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
233 pub click_count: Rc<Cell<u8>>,
235 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
237 pub scroll_offset: Rc<Cell<f32>>,
240 pub last_pointer_source: Rc<Cell<PointerSource>>,
244 pub node_origin: Rc<Cell<Point>>,
248}
249
250impl TextFieldRefs {
251 pub fn new() -> Self {
253 Self {
254 is_focused: Rc::new(RefCell::new(false)),
255 content_offset: Rc::new(Cell::new(0.0_f32)),
256 content_y_offset: Rc::new(Cell::new(0.0_f32)),
257 drag_anchor: Rc::new(Cell::new(None::<usize>)),
258 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
259 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
260 click_count: Rc::new(Cell::new(0_u8)),
261 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
262 scroll_offset: Rc::new(Cell::new(0.0_f32)),
263 last_pointer_source: Rc::new(Cell::new(PointerSource::Unknown)),
264 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
265 }
266 }
267}
268
269use crate::text::TextStyle; pub struct TextFieldModifierNode {
278 state: TextFieldState,
280 refs: TextFieldRefs,
282 style: TextStyle, cursor_brush: Brush,
286 selection_brush: Brush,
288 line_limits: TextFieldLineLimits,
290 cached_text: String,
292 cached_selection: TextRange,
294 node_state: NodeState,
296 measured_size: Rc<Cell<Size>>,
298 measured_line_height: Rc<Cell<f32>>,
300 measured_wrap_width: Rc<Cell<Option<f32>>>,
306 cached_handler: Rc<dyn Fn(PointerEvent)>,
308 cached_pan_resolver: TextPanResolver,
310 handle_controller: Option<TextFieldHandleController>,
314}
315
316impl std::fmt::Debug for TextFieldModifierNode {
317 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 f.debug_struct("TextFieldModifierNode")
319 .field("text", &self.state.text())
320 .field("style", &self.style)
321 .field("is_focused", &*self.refs.is_focused.borrow())
322 .finish()
323 }
324}
325
326use crate::text_field_handler::TextFieldHandler;
328
329impl TextFieldModifierNode {
330 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
332 let value = state.value();
333 let refs = TextFieldRefs::new();
334 let line_limits = TextFieldLineLimits::default();
335 let cached_handler =
336 Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
337 let cached_pan_resolver =
338 Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
339
340 Self {
341 state,
342 refs,
343 style,
344 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
345 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
346 line_limits,
347 cached_text: value.text,
348 cached_selection: value.selection,
349 node_state: NodeState::new(),
350 measured_size: Rc::new(Cell::new(Size {
351 width: 0.0,
352 height: 0.0,
353 })),
354 measured_line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
355 measured_wrap_width: Rc::new(Cell::new(None)),
356 cached_handler,
357 cached_pan_resolver,
358 handle_controller: None,
359 }
360 }
361
362 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
364 self.line_limits = line_limits;
365 self.cached_pan_resolver = Self::create_pan_resolver(
366 self.state.clone(),
367 self.refs.clone(),
368 line_limits,
369 self.style.clone(),
370 );
371 self
372 }
373
374 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
376 self.handle_controller = Some(controller);
377 self
378 }
379
380 fn create_pan_resolver(
388 state: TextFieldState,
389 refs: TextFieldRefs,
390 line_limits: TextFieldLineLimits,
391 style: TextStyle,
392 ) -> TextPanResolver {
393 Rc::new(move |viewport_width: f32| {
394 if !line_limits.is_single_line() {
395 refs.scroll_offset.set(0.0);
397 return 0.0;
398 }
399 let text = state.text();
400 let pos = state.selection().start.min(text.len());
401 let text_width = crate::text::measure_text(
402 &crate::text::AnnotatedString::from(text.as_str()),
403 &style,
404 )
405 .width;
406 let cursor_x = crate::text::measure_text(
407 &crate::text::AnnotatedString::from(&text[..pos]),
408 &style,
409 )
410 .width;
411 let offset = compute_horizontal_scroll_offset(
412 refs.scroll_offset.get(),
413 cursor_x,
414 text_width,
415 viewport_width,
416 );
417 refs.scroll_offset.set(offset);
418 offset
419 })
420 }
421
422 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
427 self.line_limits
428 .is_single_line()
429 .then(|| self.cached_pan_resolver.clone())
430 }
431
432 pub fn scroll_offset(&self) -> f32 {
434 self.refs.scroll_offset.get()
435 }
436
437 pub fn line_limits(&self) -> TextFieldLineLimits {
439 self.line_limits
440 }
441
442 fn create_handler(
444 state: TextFieldState,
445 refs: TextFieldRefs,
446 line_limits: TextFieldLineLimits,
447 style: TextStyle, ) -> Rc<dyn Fn(PointerEvent)> {
449 use crate::text_selection::{
452 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
453 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
454 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
455 };
456 use crate::word_boundaries::find_word_boundaries;
457
458 Rc::new(move |event: PointerEvent| {
459 refs.node_origin.set(Point {
466 x: event.global_position.x - event.position.x,
467 y: event.global_position.y - event.position.y,
468 });
469
470 let click_x =
474 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
475 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
476
477 match event.kind {
478 PointerEventKind::Down => {
479 refs.last_pointer_source.set(event.source);
483
484 let handler = TextFieldHandler::new(
489 state.clone(),
490 refs.node_id.get(),
491 line_limits,
492 crate::text_field_handler::CaretGeometryRefs {
493 node_origin: refs.node_origin.clone(),
494 content_offset: refs.content_offset.clone(),
495 content_y_offset: refs.content_y_offset.clone(),
496 scroll_offset: refs.scroll_offset.clone(),
497 style: style.clone(),
498 },
499 );
500 crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
501
502 let now = web_time::Instant::now();
503 let text = state.text();
504 let pos = crate::text::get_offset_for_position(
505 &crate::text::AnnotatedString::from(text.as_str()),
506 &style,
507 click_x,
508 click_y,
509 );
510
511 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
516 let count = refs.click_count.get();
517 (count > 0).then_some((count, px, py))
518 });
519 let elapsed_ms = refs
520 .last_click_time
521 .get()
522 .map(|last| now.duration_since(last).as_millis())
523 .unwrap_or(u128::MAX);
524 let tap_count = classify_tap_count(
525 previous,
526 elapsed_ms,
527 event.position.x,
528 event.position.y,
529 MULTI_TAP_TIMEOUT_MS,
530 MULTI_TAP_SLOP_PX,
531 );
532
533 let selection = state.selection();
541 let tap_in_selection =
542 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
543 let repeat_in_place = refs
546 .last_click_pos
547 .get()
548 .map(|(px, py)| {
549 let dx = event.position.x - px;
550 let dy = event.position.y - py;
551 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
552 })
553 .unwrap_or(false);
554 let effective_count = resolve_selection_tap_count(
555 tap_count,
556 refs.click_count.get(),
557 tap_in_selection,
558 repeat_in_place,
559 );
560
561 match tap_selection_granularity(effective_count) {
562 SelectionGranularity::Paragraph => {
563 let (start, end) = find_paragraph_boundaries(&text, pos);
565 state.edit(|buffer| {
566 buffer.select(TextRange::new(start, end));
567 });
568 refs.drag_anchor.set(Some(start));
569 }
570 SelectionGranularity::Line => {
571 let (line_start, line_end) = find_line_boundaries(&text, pos);
573 state.edit(|buffer| {
574 buffer.select(TextRange::new(line_start, line_end));
575 });
576 refs.drag_anchor.set(Some(line_start));
577 }
578 SelectionGranularity::Word => {
579 let (word_start, word_end) = find_word_boundaries(&text, pos);
582 state.edit(|buffer| {
583 buffer.select(TextRange::new(word_start, word_end));
584 });
585 refs.drag_anchor.set(Some(word_start));
586 }
587 SelectionGranularity::Caret => {
588 refs.drag_anchor.set(Some(pos));
590 state.edit(|buffer| {
591 buffer.place_cursor_before_char(pos);
592 });
593 }
594 }
595
596 refs.click_count.set(effective_count);
597 refs.last_click_time.set(Some(now));
598 refs.last_click_pos
599 .set(Some((event.position.x, event.position.y)));
600 event.consume();
601 }
602 PointerEventKind::Move => {
603 if let Some(anchor) = refs.drag_anchor.get() {
605 if *refs.is_focused.borrow() {
606 let text = state.text();
607 let current_pos = crate::text::get_offset_for_position(
608 &crate::text::AnnotatedString::from(text.as_str()),
609 &style,
610 click_x,
611 click_y,
612 );
613
614 state.set_selection(TextRange::new(anchor, current_pos));
616
617 crate::request_render_invalidation();
619
620 event.consume();
621 }
622 }
623 }
624 PointerEventKind::Up => {
625 refs.drag_anchor.set(None);
627 }
628 _ => {}
629 }
630 })
631 }
632
633 pub fn with_cursor_color(mut self, color: Color) -> Self {
635 self.cursor_brush = Brush::solid(color);
636 self
637 }
638
639 pub fn set_focused(&mut self, focused: bool) {
641 let current = *self.refs.is_focused.borrow();
642 if current != focused {
643 *self.refs.is_focused.borrow_mut() = focused;
644 }
645 }
646
647 pub fn is_focused(&self) -> bool {
649 *self.refs.is_focused.borrow()
650 }
651
652 pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
654 self.refs.is_focused.clone()
655 }
656
657 pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
659 self.refs.content_offset.clone()
660 }
661
662 pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
664 self.refs.content_y_offset.clone()
665 }
666
667 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
680 self.refs.node_origin.clone()
681 }
682
683 pub fn text(&self) -> String {
685 self.state.text()
686 }
687
688 pub fn style(&self) -> &TextStyle {
689 &self.style
690 }
691
692 pub fn selection(&self) -> TextRange {
694 self.state.selection()
695 }
696
697 pub fn cursor_brush(&self) -> Brush {
699 self.cursor_brush.clone()
700 }
701
702 pub fn selection_brush(&self) -> Brush {
704 self.selection_brush.clone()
705 }
706
707 pub fn insert_text(&mut self, text: &str) {
709 self.state.edit(|buffer| {
710 buffer.insert(text);
711 });
712 }
713
714 pub fn copy_selection(&self) -> Option<String> {
717 self.state.copy_selection()
718 }
719
720 pub fn cut_selection(&mut self) -> Option<String> {
723 let text = self.copy_selection();
724 if text.is_some() {
725 self.state.edit(|buffer| {
726 buffer.delete(buffer.selection());
727 });
728 }
729 text
730 }
731
732 pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
735 self.state.clone()
736 }
737
738 pub fn set_content_offset(&self, offset: f32) {
741 self.refs.content_offset.set(offset);
742 }
743
744 pub fn set_content_y_offset(&self, offset: f32) {
747 self.refs.content_y_offset.set(offset);
748 }
749
750 fn wrap_width(&self, available_width: f32) -> Option<f32> {
757 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
758 .then_some(available_width)
759 }
760
761 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
767 let text = self.state.text();
768 let node_id = self.refs.node_id.get();
769 let annotated = crate::text::AnnotatedString::from(text.as_str());
770 let metrics = match wrap_width {
771 Some(max_width) => crate::text::measure_text_with_options_for_node(
772 node_id,
773 &annotated,
774 &self.style,
775 crate::text::TextLayoutOptions::default(),
776 Some(max_width),
777 ),
778 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
779 };
780 self.measured_line_height.set(metrics.line_height);
781 Size {
782 width: metrics.width,
783 height: metrics.height,
784 }
785 }
786
787 fn update_cached_state(&mut self) -> bool {
789 let value = self.state.value();
790 let text_changed = value.text != self.cached_text;
791 let selection_changed = value.selection != self.cached_selection;
792
793 if text_changed {
794 self.cached_text = value.text;
795 }
796 if selection_changed {
797 self.cached_selection = value.selection;
798 }
799
800 text_changed || selection_changed
801 }
802
803 pub fn position_cursor_at_offset(&self, x_offset: f32) {
806 let text = self.state.text();
807 if text.is_empty() {
808 self.state.edit(|buffer| {
809 buffer.place_cursor_at_start();
810 });
811 return;
812 }
813
814 let byte_offset = crate::text::get_offset_for_position(
817 &crate::text::AnnotatedString::from(text.as_str()),
818 &self.style,
819 x_offset + self.refs.scroll_offset.get(),
820 0.0,
821 );
822
823 self.state.edit(|buffer| {
824 buffer.place_cursor_before_char(byte_offset);
825 });
826 }
827
828 }
832
833impl DelegatableNode for TextFieldModifierNode {
834 fn node_state(&self) -> &NodeState {
835 &self.node_state
836 }
837}
838
839impl ModifierNode for TextFieldModifierNode {
840 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
841 self.refs.node_id.set(context.node_id());
843
844 context.invalidate(InvalidationKind::Layout);
845 context.invalidate(InvalidationKind::Draw);
846 context.invalidate(InvalidationKind::Semantics);
847 }
848
849 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
850 Some(self)
851 }
852
853 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
854 Some(self)
855 }
856
857 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
858 Some(self)
859 }
860
861 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
862 Some(self)
863 }
864
865 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
866 Some(self)
867 }
868
869 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
870 Some(self)
871 }
872
873 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
874 Some(self)
875 }
876
877 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
878 Some(self)
879 }
880}
881
882impl LayoutModifierNode for TextFieldModifierNode {
883 fn measure(
884 &self,
885 _context: &mut dyn ModifierNodeContext,
886 _measurable: &dyn Measurable,
887 constraints: Constraints,
888 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
889 let wrap_width = self.wrap_width(constraints.max_width);
893 self.measured_wrap_width.set(wrap_width);
896 let text_size = self.measure_text_content(wrap_width);
897
898 let min_height = if text_size.height < 1.0 {
900 DEFAULT_LINE_HEIGHT
901 } else {
902 text_size.height
903 };
904
905 let width = text_size
907 .width
908 .max(constraints.min_width)
909 .min(constraints.max_width);
910 let height = min_height
911 .max(constraints.min_height)
912 .min(constraints.max_height);
913
914 let size = Size { width, height };
915 self.measured_size.set(size);
916
917 let _ = (self.cached_pan_resolver)(size.width);
920
921 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
922 }
923
924 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
925 self.measure_text_content(None).width
926 }
927
928 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
929 self.measure_text_content(None).width
930 }
931
932 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
933 self.measure_text_content(self.wrap_width(width))
934 .height
935 .max(DEFAULT_LINE_HEIGHT)
936 }
937
938 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
939 self.measure_text_content(self.wrap_width(width))
940 .height
941 .max(DEFAULT_LINE_HEIGHT)
942 }
943}
944
945impl DrawModifierNode for TextFieldModifierNode {
946 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
947 }
951
952 fn create_draw_closure(
953 &self,
954 ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
955 {
956 use cranpose_ui_graphics::DrawPrimitive;
957
958 let is_focused = self.refs.is_focused.clone();
960 let state = self.state.clone();
961 let content_offset = self.refs.content_offset.clone();
962 let content_y_offset = self.refs.content_y_offset.clone();
963 let cursor_brush = self.cursor_brush.clone();
964 let selection_brush = self.selection_brush.clone();
965 let style = self.style.clone();
966 let cached_line_height = self.measured_line_height.clone();
967 let measured_size = self.measured_size.clone();
968 let measured_wrap_width = self.measured_wrap_width.clone();
969 let node_id = self.refs.node_id.clone();
970 let pan_resolver = self.cached_pan_resolver.clone();
971 let handle_controller = self.handle_controller.clone();
972 let node_origin = self.refs.node_origin.clone();
973 let last_pointer_source = self.refs.last_pointer_source.clone();
974
975 Some(Rc::new(move |size| {
976 if !*is_focused.borrow() {
978 if let Some(controller) = &handle_controller {
981 controller.publish(TextFieldHandleMetrics {
982 focused: false,
983 touch: false,
984 node_origin: node_origin.get(),
985 padding_left: 0.0,
986 padding_top: 0.0,
987 scroll_offset: 0.0,
988 line_height: cached_line_height.get(),
989 wrap_width: measured_wrap_width.get(),
990 });
991 }
992 return vec![];
993 }
994
995 let mut primitives = Vec::new();
996
997 let text = state.text();
998 let selection = state.selection();
999 let padding_left = content_offset.get();
1000 let padding_top = content_y_offset.get();
1001 let line_height = cached_line_height.get();
1004
1005 let measured = measured_size.get();
1008 let viewport_width = if measured.width > 0.0 {
1009 measured.width
1010 } else {
1011 (size.width - padding_left).max(0.0)
1012 };
1013 let viewport_height = if measured.height > 0.0 {
1014 measured.height
1015 } else {
1016 (size.height - padding_top).max(0.0)
1017 };
1018 let pan = pan_resolver(viewport_width);
1020
1021 if let Some(controller) = &handle_controller {
1024 controller.publish(TextFieldHandleMetrics {
1025 focused: true,
1026 touch: last_pointer_source.get().is_touch_like(),
1027 node_origin: node_origin.get(),
1028 padding_left,
1029 padding_top,
1030 scroll_offset: pan,
1031 line_height,
1032 wrap_width: measured_wrap_width.get(),
1033 });
1034 }
1035 let clip_bounds = cranpose_ui_graphics::Rect {
1039 x: padding_left,
1040 y: padding_top,
1041 width: viewport_width,
1042 height: viewport_height,
1043 };
1044
1045 if !selection.collapsed() {
1047 let sel_start = selection.min();
1048 let sel_end = selection.max();
1049
1050 let lines: Vec<&str> = text.split('\n').collect();
1051 let mut byte_offset: usize = 0;
1052
1053 for (line_idx, line) in lines.iter().enumerate() {
1054 let line_start = byte_offset;
1055 let line_end = byte_offset + line.len();
1056
1057 if sel_end > line_start && sel_start < line_end {
1058 let sel_start_in_line = sel_start.saturating_sub(line_start);
1059 let sel_end_in_line = (sel_end - line_start).min(line.len());
1060
1061 let sel_start_x = crate::text::measure_text(
1062 &crate::text::AnnotatedString::from(&line[..sel_start_in_line]),
1063 &style,
1064 )
1065 .width
1066 + padding_left
1067 - pan;
1068 let sel_end_x = crate::text::measure_text(
1069 &crate::text::AnnotatedString::from(&line[..sel_end_in_line]),
1070 &style,
1071 )
1072 .width
1073 + padding_left
1074 - pan;
1075 let sel_width = sel_end_x - sel_start_x;
1076
1077 if sel_width > 0.0 {
1078 let sel_rect = cranpose_ui_graphics::Rect {
1079 x: sel_start_x,
1080 y: padding_top + line_idx as f32 * line_height,
1081 width: sel_width,
1082 height: line_height,
1083 };
1084 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1085 primitives.push(DrawPrimitive::Rect {
1086 rect: clipped,
1087 brush: selection_brush.clone(),
1088 });
1089 }
1090 }
1091 }
1092 byte_offset = line_end + 1;
1093 }
1094 }
1095
1096 if let Some(comp_range) = state.composition() {
1099 let comp_start = comp_range.min();
1100 let comp_end = comp_range.max();
1101
1102 if comp_start < comp_end && comp_end <= text.len() {
1103 let lines: Vec<&str> = text.split('\n').collect();
1104 let mut byte_offset: usize = 0;
1105
1106 let underline_brush = cranpose_ui_graphics::Brush::solid(
1108 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1109 );
1110 let underline_height: f32 = 2.0;
1111
1112 for (line_idx, line) in lines.iter().enumerate() {
1113 let line_start = byte_offset;
1114 let line_end = byte_offset + line.len();
1115
1116 if comp_end > line_start && comp_start < line_end {
1118 let comp_start_in_line = comp_start.saturating_sub(line_start);
1119 let comp_end_in_line = (comp_end - line_start).min(line.len());
1120
1121 let comp_start_in_line = if line.is_char_boundary(comp_start_in_line) {
1123 comp_start_in_line
1124 } else {
1125 0
1126 };
1127 let comp_end_in_line = if line.is_char_boundary(comp_end_in_line) {
1128 comp_end_in_line
1129 } else {
1130 line.len()
1131 };
1132
1133 let comp_start_x = crate::text::measure_text(
1134 &crate::text::AnnotatedString::from(&line[..comp_start_in_line]),
1135 &style,
1136 )
1137 .width
1138 + padding_left
1139 - pan;
1140 let comp_end_x = crate::text::measure_text(
1141 &crate::text::AnnotatedString::from(&line[..comp_end_in_line]),
1142 &style,
1143 )
1144 .width
1145 + padding_left
1146 - pan;
1147 let comp_width = comp_end_x - comp_start_x;
1148
1149 if comp_width > 0.0 {
1150 let underline_rect = cranpose_ui_graphics::Rect {
1152 x: comp_start_x,
1153 y: padding_top + (line_idx as f32 + 1.0) * line_height
1154 - underline_height,
1155 width: comp_width,
1156 height: underline_height,
1157 };
1158 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1159 primitives.push(DrawPrimitive::Rect {
1160 rect: clipped,
1161 brush: underline_brush.clone(),
1162 });
1163 }
1164 }
1165 }
1166 byte_offset = line_end + 1;
1167 }
1168 }
1169 }
1170
1171 if crate::cursor_animation::is_cursor_visible() {
1173 let pos = selection.start.min(text.len());
1174 let (line_index, line_start) = caret_visual_line_for_offset(
1180 &text,
1181 &style,
1182 node_id.get(),
1183 measured_wrap_width.get(),
1184 pos,
1185 );
1186 let cursor_x = crate::text::measure_text(
1187 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1188 &style,
1189 )
1190 .width
1191 + padding_left
1192 - pan;
1193 let cursor_y = padding_top + line_index as f32 * line_height;
1194
1195 let cursor_rect = cranpose_ui_graphics::Rect {
1196 x: cursor_x,
1197 y: cursor_y,
1198 width: CURSOR_WIDTH,
1199 height: line_height,
1200 };
1201
1202 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1203 primitives.push(DrawPrimitive::Rect {
1204 rect: clipped,
1205 brush: cursor_brush.clone(),
1206 });
1207 }
1208 }
1209
1210 primitives
1211 }))
1212 }
1213}
1214
1215impl SemanticsNode for TextFieldModifierNode {
1216 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1217 let text = self.state.text();
1218 config.content_description = Some(text);
1219 config.is_editable_text = true;
1220 config.text_selection = Some(self.state.selection());
1221 }
1222}
1223
1224impl PointerInputNode for TextFieldModifierNode {
1225 fn on_pointer_event(
1226 &mut self,
1227 _context: &mut dyn ModifierNodeContext,
1228 _event: &PointerEvent,
1229 ) -> bool {
1230 false
1241 }
1242
1243 fn hit_test(&self, x: f32, y: f32) -> bool {
1244 let size = self.measured_size.get();
1246 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1247 }
1248
1249 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1250 Some(self.cached_handler.clone())
1252 }
1253}
1254
1255#[derive(Clone)]
1266pub struct TextFieldElement {
1267 state: TextFieldState,
1269 style: TextStyle,
1271 cursor_color: Color,
1273 line_limits: TextFieldLineLimits,
1275 handle_controller: Option<TextFieldHandleController>,
1278}
1279
1280impl TextFieldElement {
1281 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1283 Self {
1284 state,
1285 style,
1286 cursor_color: DEFAULT_CURSOR_COLOR,
1287 line_limits: TextFieldLineLimits::default(),
1288 handle_controller: None,
1289 }
1290 }
1291
1292 pub fn with_cursor_color(mut self, color: Color) -> Self {
1294 self.cursor_color = color;
1295 self
1296 }
1297
1298 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1300 self.line_limits = line_limits;
1301 self
1302 }
1303
1304 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1306 self.handle_controller = Some(controller);
1307 self
1308 }
1309}
1310
1311impl std::fmt::Debug for TextFieldElement {
1312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1313 f.debug_struct("TextFieldElement")
1314 .field("text", &self.state.text())
1315 .field("style", &self.style)
1316 .field("cursor_color", &self.cursor_color)
1317 .finish()
1318 }
1319}
1320
1321impl Hash for TextFieldElement {
1322 fn hash<H: Hasher>(&self, state: &mut H) {
1323 std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1326 self.cursor_color.0.to_bits().hash(state);
1328 self.cursor_color.1.to_bits().hash(state);
1329 self.cursor_color.2.to_bits().hash(state);
1330 self.cursor_color.3.to_bits().hash(state);
1331 self.style.render_hash().hash(state);
1332 self.line_limits.hash(state);
1333 }
1334}
1335
1336impl PartialEq for TextFieldElement {
1337 fn eq(&self, other: &Self) -> bool {
1338 self.state == other.state
1342 && self.style == other.style
1343 && self.cursor_color == other.cursor_color
1344 && self.line_limits == other.line_limits
1345 }
1346}
1347
1348impl Eq for TextFieldElement {}
1349
1350impl ModifierNodeElement for TextFieldElement {
1351 type Node = TextFieldModifierNode;
1352
1353 fn create(&self) -> Self::Node {
1354 let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1355 .with_cursor_color(self.cursor_color)
1356 .with_line_limits(self.line_limits);
1357 if let Some(controller) = self.handle_controller.clone() {
1358 node = node.with_handle_controller(controller);
1359 }
1360 node
1361 }
1362
1363 fn update(&self, node: &mut Self::Node) {
1364 node.state = self.state.clone();
1366 node.style = self.style.clone();
1367 node.cursor_brush = Brush::solid(self.cursor_color);
1368 node.line_limits = self.line_limits;
1369 node.handle_controller = self.handle_controller.clone();
1370
1371 node.cached_handler = TextFieldModifierNode::create_handler(
1373 node.state.clone(),
1374 node.refs.clone(),
1375 node.line_limits,
1376 self.style.clone(),
1377 );
1378
1379 node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1381 node.state.clone(),
1382 node.refs.clone(),
1383 node.line_limits,
1384 self.style.clone(),
1385 );
1386
1387 if node.update_cached_state() {
1389 }
1392 }
1393
1394 fn capabilities(&self) -> NodeCapabilities {
1395 NodeCapabilities::LAYOUT
1396 | NodeCapabilities::DRAW
1397 | NodeCapabilities::SEMANTICS
1398 | NodeCapabilities::POINTER_INPUT
1399 }
1400
1401 fn always_update(&self) -> bool {
1402 true
1404 }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use crate::text::TextStyle;
1411 use cranpose_core::{DefaultScheduler, Runtime};
1412 use std::sync::Arc;
1413
1414 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1416 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1417 f()
1418 }
1419
1420 #[test]
1421 fn text_field_node_creation() {
1422 let _app_context = crate::render_state::app_context_test_scope();
1423 with_test_runtime(|| {
1424 let state = TextFieldState::new("Hello");
1425 let node = TextFieldModifierNode::new(state, TextStyle::default());
1426 assert_eq!(node.text(), "Hello");
1427 assert!(!node.is_focused());
1428 });
1429 }
1430
1431 #[test]
1432 fn text_field_node_focus() {
1433 let _app_context = crate::render_state::app_context_test_scope();
1434 with_test_runtime(|| {
1435 let state = TextFieldState::new("Test");
1436 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1437 assert!(!node.is_focused());
1438
1439 node.set_focused(true);
1440 assert!(node.is_focused());
1441
1442 node.set_focused(false);
1443 assert!(!node.is_focused());
1444 });
1445 }
1446
1447 #[test]
1448 fn text_field_element_creates_node() {
1449 let _app_context = crate::render_state::app_context_test_scope();
1450 with_test_runtime(|| {
1451 let state = TextFieldState::new("Hello World");
1452 let element = TextFieldElement::new(state, TextStyle::default());
1453
1454 let node = element.create();
1455 assert_eq!(node.text(), "Hello World");
1456 });
1457 }
1458
1459 #[test]
1468 fn touch_press_publishes_touch_handle_metrics_but_mouse_does_not() {
1469 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1470 use cranpose_ui_graphics::Point;
1471
1472 let _app_context = crate::render_state::app_context_test_scope();
1473 with_test_runtime(|| {
1474 let state = TextFieldState::new("hello world");
1475 let controller = TextFieldHandleController::new();
1476 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1477 .with_handle_controller(controller.clone());
1478 node.measured_size.set(Size {
1480 width: 120.0,
1481 height: 20.0,
1482 });
1483
1484 let handler = node
1485 .pointer_input_handler()
1486 .expect("field exposes a pointer handler");
1487 let draw = node
1488 .create_draw_closure()
1489 .expect("field exposes a draw closure");
1490 let at = Point { x: 12.0, y: 8.0 };
1491 let size = Size {
1492 width: 120.0,
1493 height: 20.0,
1494 };
1495
1496 handler(
1499 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1500 );
1501 let _ = draw(size);
1502 let metrics = controller
1503 .metrics()
1504 .expect("focused field publishes handle metrics");
1505 assert!(metrics.focused, "a tap focuses the field");
1506 assert!(
1507 metrics.touch,
1508 "a touch tap must publish touch = true so the finger handles show"
1509 );
1510
1511 handler(
1514 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1515 );
1516 let _ = draw(size);
1517 let metrics = controller
1518 .metrics()
1519 .expect("focused field publishes handle metrics");
1520 assert!(
1521 !metrics.touch,
1522 "a mouse tap must publish touch = false (clean caret, no finger handle)"
1523 );
1524
1525 crate::text_field_focus::clear_focus();
1526 });
1527 }
1528
1529 #[test]
1537 fn double_tap_selects_the_word_under_the_finger() {
1538 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1539 use cranpose_ui_graphics::Point;
1540
1541 let _app_context = crate::render_state::app_context_test_scope();
1542 with_test_runtime(|| {
1543 let state = TextFieldState::new("hello world");
1544 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1545 node.measured_size.set(Size {
1546 width: 200.0,
1547 height: 20.0,
1548 });
1549 let handler = node
1550 .pointer_input_handler()
1551 .expect("field exposes a pointer handler");
1552
1553 let at = Point { x: 2.0, y: 8.0 };
1556 handler(
1557 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1558 );
1559 handler(
1560 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1561 );
1562
1563 let selection = state.selection();
1564 assert!(
1565 !selection.collapsed(),
1566 "a double tap must produce a (word) selection, got {selection:?}"
1567 );
1568 let selected = &state.text()[selection.min()..selection.max()];
1569 assert_eq!(
1570 selected, "hello",
1571 "double tap should select the whole word under the finger"
1572 );
1573
1574 crate::text_field_focus::clear_focus();
1575 });
1576 }
1577
1578 #[test]
1582 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1583 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1584 use cranpose_ui_graphics::Point;
1585
1586 let _app_context = crate::render_state::app_context_test_scope();
1587 with_test_runtime(|| {
1588 let text = "alpha beta\ngamma delta\n\nsecond para";
1591 let state = TextFieldState::new(text);
1592 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1593 .with_line_limits(TextFieldLineLimits::MultiLine {
1594 min_lines: 1,
1595 max_lines: usize::MAX,
1596 });
1597 node.measured_size.set(Size {
1598 width: 400.0,
1599 height: 80.0,
1600 });
1601 let handler = node
1602 .pointer_input_handler()
1603 .expect("field exposes a pointer handler");
1604
1605 let at = Point { x: 2.0, y: 4.0 };
1607 let tap = || {
1608 handler(
1609 PointerEvent::new(PointerEventKind::Down, at, at)
1610 .with_source(PointerSource::Touch),
1611 );
1612 };
1613 let selected = |state: &TextFieldState| {
1614 let s = state.selection();
1615 state.text()[s.min()..s.max()].to_string()
1616 };
1617
1618 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1620 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1622 tap(); assert_eq!(
1624 selected(&state),
1625 "alpha beta",
1626 "triple tap selects the line"
1627 );
1628 tap(); assert_eq!(
1630 selected(&state),
1631 "alpha beta\ngamma delta",
1632 "fourth tap grows to the paragraph"
1633 );
1634 tap(); assert_eq!(
1636 selected(&state),
1637 "alpha",
1638 "fifth tap cycles back to the word"
1639 );
1640
1641 crate::text_field_focus::clear_focus();
1642 });
1643 }
1644
1645 #[test]
1649 fn single_tap_inside_selection_selects_the_word() {
1650 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1651 use cranpose_ui_graphics::Point;
1652
1653 let _app_context = crate::render_state::app_context_test_scope();
1654 with_test_runtime(|| {
1655 let state = TextFieldState::new("hello world");
1656 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1657 node.measured_size.set(Size {
1658 width: 200.0,
1659 height: 20.0,
1660 });
1661 let handler = node
1662 .pointer_input_handler()
1663 .expect("field exposes a pointer handler");
1664
1665 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1667 assert!(!state.selection().collapsed());
1668
1669 let at = Point { x: 2.0, y: 8.0 };
1672 handler(
1673 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1674 );
1675
1676 let selection = state.selection();
1677 assert!(
1678 !selection.collapsed(),
1679 "a tap inside a selection must not collapse it, got {selection:?}"
1680 );
1681 assert_eq!(
1682 &state.text()[selection.min()..selection.max()],
1683 "hello",
1684 "a tap inside a selection re-selects the word under the finger"
1685 );
1686
1687 crate::text_field_focus::clear_focus();
1688 });
1689 }
1690
1691 #[test]
1699 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
1700 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1701 use cranpose_ui_graphics::Point;
1702
1703 let _app_context = crate::render_state::app_context_test_scope();
1704 with_test_runtime(|| {
1705 let text = "alpha beta\ngamma delta\n\nsecond para";
1706 let state = TextFieldState::new(text);
1707 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1708 .with_line_limits(TextFieldLineLimits::MultiLine {
1709 min_lines: 1,
1710 max_lines: usize::MAX,
1711 });
1712 node.measured_size.set(Size {
1713 width: 400.0,
1714 height: 80.0,
1715 });
1716 let handler = node
1717 .pointer_input_handler()
1718 .expect("field exposes a pointer handler");
1719
1720 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
1722
1723 let at = Point { x: 2.0, y: 4.0 };
1724 let selected = |state: &TextFieldState| {
1725 let s = state.selection();
1726 state.text()[s.min()..s.max()].to_string()
1727 };
1728 let slow_tap = || {
1731 node.refs.last_click_time.set(None);
1732 handler(
1733 PointerEvent::new(PointerEventKind::Down, at, at)
1734 .with_source(PointerSource::Touch),
1735 );
1736 };
1737
1738 slow_tap(); assert_eq!(
1740 selected(&state),
1741 "alpha",
1742 "tap inside selection grabs the word"
1743 );
1744 slow_tap(); assert_eq!(
1746 selected(&state),
1747 "alpha beta",
1748 "same-spot tap grows to the line even after the timeout"
1749 );
1750 slow_tap(); assert_eq!(
1752 selected(&state),
1753 "alpha beta\ngamma delta",
1754 "same-spot tap grows to the paragraph"
1755 );
1756 slow_tap(); assert_eq!(
1758 selected(&state),
1759 "alpha",
1760 "same-spot tap cycles back to the word"
1761 );
1762
1763 crate::text_field_focus::clear_focus();
1764 });
1765 }
1766
1767 #[test]
1768 fn text_field_element_equality() {
1769 let _app_context = crate::render_state::app_context_test_scope();
1770 with_test_runtime(|| {
1771 let state1 = TextFieldState::new("Hello");
1772 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
1775 let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
1781 assert_ne!(elem1, elem3, "Different states should not be equal");
1782 });
1783 }
1784
1785 #[test]
1786 fn text_field_element_update_refreshes_existing_node_style() {
1787 let _app_context = crate::render_state::app_context_test_scope();
1788 with_test_runtime(|| {
1789 let state = TextFieldState::new("themed text");
1790 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1791 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1792 ..crate::text::SpanStyle::default()
1793 });
1794 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1795 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1796 ..crate::text::SpanStyle::default()
1797 });
1798 let initial = TextFieldElement::new(state.clone(), dark_style);
1799 let updated = TextFieldElement::new(state, light_style.clone());
1800 let mut node = initial.create();
1801
1802 updated.update(&mut node);
1803
1804 assert_eq!(node.text(), "themed text");
1805 assert_eq!(node.style(), &light_style);
1806 });
1807 }
1808
1809 #[test]
1814 fn multiline_field_measures_wrapped_height() {
1815 let _app_context = crate::render_state::app_context_test_scope();
1816 with_test_runtime(|| {
1817 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
1819 let node = TextFieldModifierNode::new(state, TextStyle::default());
1820 assert!(
1821 !node.line_limits().is_single_line(),
1822 "default fields are multi-line"
1823 );
1824
1825 let natural = node.measure_text_content(None);
1826 let wrapped = node.measure_text_content(node.wrap_width(20.0));
1827
1828 assert!(
1829 wrapped.height > natural.height,
1830 "wrapped multi-line height {} must exceed the single-line height {}",
1831 wrapped.height,
1832 natural.height
1833 );
1834 });
1835 }
1836
1837 #[test]
1840 fn single_line_field_never_wraps() {
1841 let _app_context = crate::render_state::app_context_test_scope();
1842 with_test_runtime(|| {
1843 let state = TextFieldState::new("abcd ".repeat(40));
1844 let node = TextFieldModifierNode::new(state, TextStyle::default())
1845 .with_line_limits(TextFieldLineLimits::SingleLine);
1846 assert_eq!(
1847 node.wrap_width(20.0),
1848 None,
1849 "single-line fields must not wrap"
1850 );
1851 });
1852 }
1853
1854 #[test]
1860 fn test_cursor_x_position_calculation() {
1861 let _app_context = crate::render_state::app_context_test_scope();
1862 with_test_runtime(|| {
1863 let style = crate::text::TextStyle::default();
1865
1866 let empty_width =
1868 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1869 assert!(
1870 empty_width.abs() < 0.1,
1871 "Empty text should have 0 width, got {}",
1872 empty_width
1873 );
1874
1875 let hi_width =
1877 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1878 assert!(
1879 hi_width > 0.0,
1880 "Text 'Hi' should have positive width: {}",
1881 hi_width
1882 );
1883
1884 let h_width =
1886 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1887 assert!(h_width > 0.0, "Text 'H' should have positive width");
1888 assert!(
1889 h_width < hi_width,
1890 "'H' width {} should be less than 'Hi' width {}",
1891 h_width,
1892 hi_width
1893 );
1894
1895 let state = TextFieldState::new("Hi");
1897 assert_eq!(
1898 state.selection().start,
1899 2,
1900 "Cursor should be at position 2 (end of 'Hi')"
1901 );
1902
1903 let text = state.text();
1905 let cursor_pos = state.selection().start;
1906 let text_before_cursor = &text[..cursor_pos.min(text.len())];
1907 assert_eq!(text_before_cursor, "Hi");
1908
1909 let cursor_x = crate::text::measure_text(
1911 &crate::text::AnnotatedString::from(text_before_cursor),
1912 &style,
1913 )
1914 .width;
1915 assert!(
1916 (cursor_x - hi_width).abs() < 0.1,
1917 "Cursor x {} should equal 'Hi' width {}",
1918 cursor_x,
1919 hi_width
1920 );
1921 });
1922 }
1923
1924 #[test]
1926 fn test_focused_node_creates_cursor() {
1927 let _app_context = crate::render_state::app_context_test_scope();
1928 with_test_runtime(|| {
1929 let state = TextFieldState::new("Test");
1930 let element = TextFieldElement::new(state.clone(), TextStyle::default());
1931 let node = element.create();
1932
1933 assert!(!node.is_focused());
1935
1936 *node.refs.is_focused.borrow_mut() = true;
1938 assert!(node.is_focused());
1939
1940 assert_eq!(node.text(), "Test");
1942
1943 assert_eq!(node.selection().start, 4);
1945 });
1946 }
1947}