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 glyph_box: (f32, f32),
50 pub wrap_width: Option<f32>,
53}
54
55#[derive(Clone)]
60pub struct TextFieldHandleController {
61 inner: Rc<TextFieldHandleControllerInner>,
62}
63
64impl PartialEq for TextFieldHandleController {
65 fn eq(&self, other: &Self) -> bool {
66 Rc::ptr_eq(&self.inner, &other.inner)
67 }
68}
69
70struct TextFieldHandleControllerInner {
71 metrics: Cell<Option<TextFieldHandleMetrics>>,
72 revision: MutableState<u64>,
73}
74
75impl TextFieldHandleController {
76 pub fn new() -> Self {
79 Self {
80 inner: Rc::new(TextFieldHandleControllerInner {
81 metrics: Cell::new(None),
82 revision: mutableStateOf(0u64),
83 }),
84 }
85 }
86
87 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
90 if self.inner.metrics.get() != Some(metrics) {
91 self.inner.metrics.set(Some(metrics));
92 self.inner
93 .revision
94 .update(|value| *value = value.wrapping_add(1));
95 }
96 }
97
98 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
101 let _ = self.inner.revision.value();
102 self.inner.metrics.get()
103 }
104}
105
106impl Default for TextFieldHandleController {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
114
115const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
117
118const DEFAULT_LINE_HEIGHT: f32 = 20.0;
120
121const CURSOR_WIDTH: f32 = 2.0;
123
124pub(crate) fn compute_horizontal_scroll_offset(
135 current_offset: f32,
136 cursor_x: f32,
137 text_width: f32,
138 viewport_width: f32,
139) -> f32 {
140 if viewport_width <= 0.0 {
141 return 0.0;
142 }
143 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
144 let mut offset = current_offset.clamp(0.0, max_offset);
145 let visible_end = offset + viewport_width - CURSOR_WIDTH;
146 if cursor_x > visible_end {
147 offset = cursor_x - viewport_width + CURSOR_WIDTH;
149 } else if cursor_x < offset {
150 offset = cursor_x;
152 }
153 offset.clamp(0.0, max_offset)
154}
155
156pub(crate) fn intersect_rect(
161 rect: cranpose_ui_graphics::Rect,
162 bounds: cranpose_ui_graphics::Rect,
163) -> Option<cranpose_ui_graphics::Rect> {
164 let x0 = rect.x.max(bounds.x);
165 let y0 = rect.y.max(bounds.y);
166 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
167 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
168 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
169 x: x0,
170 y: y0,
171 width: x1 - x0,
172 height: y1 - y0,
173 })
174}
175
176pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
179
180pub(crate) fn caret_visual_line_for_offset(
190 text: &str,
191 style: &TextStyle,
192 node_id: Option<cranpose_core::NodeId>,
193 wrap_width: Option<f32>,
194 offset: usize,
195) -> (usize, usize) {
196 let offset = offset.min(text.len());
197 match wrap_width {
198 Some(width) if width.is_finite() && width > 0.0 => {
199 let annotated = crate::text::AnnotatedString::from(text);
200 let ranges = crate::text::wrapped_line_ranges(
201 node_id,
202 &annotated,
203 style,
204 crate::text::TextLayoutOptions::default(),
205 Some(width),
206 );
207 crate::text_selection::caret_visual_line(&ranges, offset)
208 }
209 _ => {
210 let before = &text[..offset];
211 let line_index = before.matches('\n').count();
212 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
213 (line_index, line_start)
214 }
215 }
216}
217
218#[allow(clippy::too_many_arguments)]
229pub(crate) fn range_visual_line_rects(
230 text: &str,
231 style: &TextStyle,
232 node_id: Option<cranpose_core::NodeId>,
233 wrap_width: Option<f32>,
234 padding_left: f32,
235 padding_top: f32,
236 pan: f32,
237 line_height: f32,
238 start: usize,
239 end: usize,
240) -> Vec<cranpose_ui_graphics::Rect> {
241 if start >= end {
242 return Vec::new();
243 }
244 let annotated = crate::text::AnnotatedString::from(text);
245 let line_ranges = crate::text::wrapped_line_ranges(
246 node_id,
247 &annotated,
248 style,
249 crate::text::TextLayoutOptions::default(),
250 wrap_width,
251 );
252 let mut rects = Vec::new();
253 for (line_idx, line_range) in line_ranges.iter().enumerate() {
254 let line_start = line_range.start;
255 let line_end = line_range.end;
256 if end <= line_start || start >= line_end {
257 continue;
258 }
259 let seg_start = start.max(line_start);
260 let seg_end = end.min(line_end);
261 let x0 = crate::text::measure_text(
262 &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
263 style,
264 )
265 .width
266 + padding_left
267 - pan;
268 let x1 = crate::text::measure_text(
269 &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
270 style,
271 )
272 .width
273 + padding_left
274 - pan;
275 let width = x1 - x0;
276 if width > 0.0 {
277 rects.push(cranpose_ui_graphics::Rect {
278 x: x0,
279 y: padding_top + line_idx as f32 * line_height,
280 width,
281 height: line_height,
282 });
283 }
284 }
285 rects
286}
287
288#[derive(Clone)]
294pub(crate) struct TextFieldRefs {
295 pub is_focused: Rc<RefCell<bool>>,
297 pub content_offset: Rc<Cell<f32>>,
299 pub content_y_offset: Rc<Cell<f32>>,
301 pub drag_anchor: Rc<Cell<Option<usize>>>,
303 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
305 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
307 pub click_count: Rc<Cell<u8>>,
309 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
311 pub scroll_offset: Rc<Cell<f32>>,
314 pub last_pointer_source: Rc<Cell<PointerSource>>,
318 pub node_origin: Rc<Cell<Point>>,
322 pub line_height: Rc<Cell<f32>>,
326 pub wrap_width: Rc<Cell<Option<f32>>>,
330}
331
332impl TextFieldRefs {
333 pub fn new() -> Self {
335 Self {
336 is_focused: Rc::new(RefCell::new(false)),
337 content_offset: Rc::new(Cell::new(0.0_f32)),
338 content_y_offset: Rc::new(Cell::new(0.0_f32)),
339 drag_anchor: Rc::new(Cell::new(None::<usize>)),
340 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
341 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
342 click_count: Rc::new(Cell::new(0_u8)),
343 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
344 scroll_offset: Rc::new(Cell::new(0.0_f32)),
345 last_pointer_source: Rc::new(Cell::new(PointerSource::Unknown)),
346 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
347 line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
348 wrap_width: Rc::new(Cell::new(None::<f32>)),
349 }
350 }
351}
352
353use crate::text::TextStyle; pub struct TextFieldModifierNode {
362 state: TextFieldState,
364 refs: TextFieldRefs,
366 style: TextStyle, cursor_brush: Brush,
370 selection_brush: Brush,
372 line_limits: TextFieldLineLimits,
374 cached_text: String,
376 cached_selection: TextRange,
378 node_state: NodeState,
380 measured_size: Rc<Cell<Size>>,
382 measured_line_height: Rc<Cell<f32>>,
384 measured_wrap_width: Rc<Cell<Option<f32>>>,
390 cached_handler: Rc<dyn Fn(PointerEvent)>,
392 cached_pan_resolver: TextPanResolver,
394 handle_controller: Option<TextFieldHandleController>,
398}
399
400impl std::fmt::Debug for TextFieldModifierNode {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 f.debug_struct("TextFieldModifierNode")
403 .field("text", &self.state.text())
404 .field("style", &self.style)
405 .field("is_focused", &*self.refs.is_focused.borrow())
406 .finish()
407 }
408}
409
410use crate::text_field_handler::TextFieldHandler;
412
413impl TextFieldModifierNode {
414 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
416 let value = state.value();
417 let refs = TextFieldRefs::new();
418 let refs_line_height = refs.line_height.clone();
419 let refs_wrap_width = refs.wrap_width.clone();
420 let line_limits = TextFieldLineLimits::default();
421 let cached_handler =
422 Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
423 let cached_pan_resolver =
424 Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
425
426 Self {
427 state,
428 refs,
429 style,
430 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
431 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
432 line_limits,
433 cached_text: value.text,
434 cached_selection: value.selection,
435 node_state: NodeState::new(),
436 measured_size: Rc::new(Cell::new(Size {
437 width: 0.0,
438 height: 0.0,
439 })),
440 measured_line_height: refs_line_height,
444 measured_wrap_width: refs_wrap_width,
445 cached_handler,
446 cached_pan_resolver,
447 handle_controller: None,
448 }
449 }
450
451 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
453 self.line_limits = line_limits;
454 self.cached_pan_resolver = Self::create_pan_resolver(
455 self.state.clone(),
456 self.refs.clone(),
457 line_limits,
458 self.style.clone(),
459 );
460 self
461 }
462
463 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
465 self.handle_controller = Some(controller);
466 self
467 }
468
469 fn create_pan_resolver(
477 state: TextFieldState,
478 refs: TextFieldRefs,
479 line_limits: TextFieldLineLimits,
480 style: TextStyle,
481 ) -> TextPanResolver {
482 Rc::new(move |viewport_width: f32| {
483 if !line_limits.is_single_line() {
484 refs.scroll_offset.set(0.0);
486 return 0.0;
487 }
488 let text = state.text();
489 let pos = state.selection().start.min(text.len());
490 let text_width = crate::text::measure_text(
491 &crate::text::AnnotatedString::from(text.as_str()),
492 &style,
493 )
494 .width;
495 let cursor_x = crate::text::measure_text(
496 &crate::text::AnnotatedString::from(&text[..pos]),
497 &style,
498 )
499 .width;
500 let offset = compute_horizontal_scroll_offset(
501 refs.scroll_offset.get(),
502 cursor_x,
503 text_width,
504 viewport_width,
505 );
506 refs.scroll_offset.set(offset);
507 offset
508 })
509 }
510
511 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
516 self.line_limits
517 .is_single_line()
518 .then(|| self.cached_pan_resolver.clone())
519 }
520
521 pub fn scroll_offset(&self) -> f32 {
523 self.refs.scroll_offset.get()
524 }
525
526 pub fn line_limits(&self) -> TextFieldLineLimits {
528 self.line_limits
529 }
530
531 fn create_handler(
533 state: TextFieldState,
534 refs: TextFieldRefs,
535 line_limits: TextFieldLineLimits,
536 style: TextStyle, ) -> Rc<dyn Fn(PointerEvent)> {
538 use crate::text_selection::{
541 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
542 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
543 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
544 };
545 use crate::word_boundaries::find_word_boundaries;
546
547 Rc::new(move |event: PointerEvent| {
548 refs.node_origin.set(Point {
555 x: event.global_position.x - event.position.x,
556 y: event.global_position.y - event.position.y,
557 });
558
559 let click_x =
563 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
564 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
565
566 match event.kind {
567 PointerEventKind::Down => {
568 refs.last_pointer_source.set(event.source);
572
573 let handler = TextFieldHandler::new(
578 state.clone(),
579 refs.node_id.get(),
580 line_limits,
581 crate::text_field_handler::CaretGeometryRefs {
582 node_origin: refs.node_origin.clone(),
583 content_offset: refs.content_offset.clone(),
584 content_y_offset: refs.content_y_offset.clone(),
585 scroll_offset: refs.scroll_offset.clone(),
586 style: style.clone(),
587 },
588 );
589 crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
590
591 let now = web_time::Instant::now();
592 let text = state.text();
593 let pos = crate::text::offset_for_position_wrapped(
594 &text,
595 &style,
596 refs.node_id.get(),
597 refs.wrap_width.get(),
598 refs.line_height.get(),
599 click_x,
600 click_y,
601 );
602
603 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
608 let count = refs.click_count.get();
609 (count > 0).then_some((count, px, py))
610 });
611 let elapsed_ms = refs
612 .last_click_time
613 .get()
614 .map(|last| now.duration_since(last).as_millis())
615 .unwrap_or(u128::MAX);
616 let tap_count = classify_tap_count(
617 previous,
618 elapsed_ms,
619 event.position.x,
620 event.position.y,
621 MULTI_TAP_TIMEOUT_MS,
622 MULTI_TAP_SLOP_PX,
623 );
624
625 let selection = state.selection();
633 let tap_in_selection =
634 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
635 let repeat_in_place = refs
638 .last_click_pos
639 .get()
640 .map(|(px, py)| {
641 let dx = event.position.x - px;
642 let dy = event.position.y - py;
643 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
644 })
645 .unwrap_or(false);
646 let effective_count = resolve_selection_tap_count(
647 tap_count,
648 refs.click_count.get(),
649 tap_in_selection,
650 repeat_in_place,
651 );
652
653 match tap_selection_granularity(effective_count) {
654 SelectionGranularity::Paragraph => {
655 let (start, end) = find_paragraph_boundaries(&text, pos);
657 state.edit(|buffer| {
658 buffer.select(TextRange::new(start, end));
659 });
660 refs.drag_anchor.set(Some(start));
661 }
662 SelectionGranularity::Line => {
663 let (line_start, line_end) = find_line_boundaries(&text, pos);
665 state.edit(|buffer| {
666 buffer.select(TextRange::new(line_start, line_end));
667 });
668 refs.drag_anchor.set(Some(line_start));
669 }
670 SelectionGranularity::Word => {
671 let (word_start, word_end) = find_word_boundaries(&text, pos);
674 state.edit(|buffer| {
675 buffer.select(TextRange::new(word_start, word_end));
676 });
677 refs.drag_anchor.set(Some(word_start));
678 }
679 SelectionGranularity::Caret => {
680 refs.drag_anchor.set(Some(pos));
682 state.edit(|buffer| {
683 buffer.place_cursor_before_char(pos);
684 });
685 }
686 }
687
688 refs.click_count.set(effective_count);
689 refs.last_click_time.set(Some(now));
690 refs.last_click_pos
691 .set(Some((event.position.x, event.position.y)));
692 event.consume();
693 }
694 PointerEventKind::Move => {
695 if let Some(anchor) = refs.drag_anchor.get() {
697 if *refs.is_focused.borrow() {
698 let text = state.text();
699 let current_pos = crate::text::offset_for_position_wrapped(
700 &text,
701 &style,
702 refs.node_id.get(),
703 refs.wrap_width.get(),
704 refs.line_height.get(),
705 click_x,
706 click_y,
707 );
708
709 state.set_selection(TextRange::new(anchor, current_pos));
711
712 crate::request_render_invalidation();
714
715 event.consume();
716 }
717 }
718 }
719 PointerEventKind::Up => {
720 refs.drag_anchor.set(None);
722 }
723 _ => {}
724 }
725 })
726 }
727
728 pub fn with_cursor_color(mut self, color: Color) -> Self {
733 self.cursor_brush = Brush::solid(color);
734 self.selection_brush = Brush::solid(
735 color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
736 );
737 self
738 }
739
740 pub fn set_focused(&mut self, focused: bool) {
742 let current = *self.refs.is_focused.borrow();
743 if current != focused {
744 *self.refs.is_focused.borrow_mut() = focused;
745 }
746 }
747
748 pub fn is_focused(&self) -> bool {
750 *self.refs.is_focused.borrow()
751 }
752
753 pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
755 self.refs.is_focused.clone()
756 }
757
758 pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
760 self.refs.content_offset.clone()
761 }
762
763 pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
765 self.refs.content_y_offset.clone()
766 }
767
768 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
781 self.refs.node_origin.clone()
782 }
783
784 pub fn text(&self) -> String {
786 self.state.text()
787 }
788
789 pub fn style(&self) -> &TextStyle {
790 &self.style
791 }
792
793 pub fn selection(&self) -> TextRange {
795 self.state.selection()
796 }
797
798 pub fn cursor_brush(&self) -> Brush {
800 self.cursor_brush.clone()
801 }
802
803 pub fn selection_brush(&self) -> Brush {
805 self.selection_brush.clone()
806 }
807
808 pub fn insert_text(&mut self, text: &str) {
810 self.state.edit(|buffer| {
811 buffer.insert(text);
812 });
813 }
814
815 pub fn copy_selection(&self) -> Option<String> {
818 self.state.copy_selection()
819 }
820
821 pub fn cut_selection(&mut self) -> Option<String> {
824 let text = self.copy_selection();
825 if text.is_some() {
826 self.state.edit(|buffer| {
827 buffer.delete(buffer.selection());
828 });
829 }
830 text
831 }
832
833 pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
836 self.state.clone()
837 }
838
839 pub fn set_content_offset(&self, offset: f32) {
842 self.refs.content_offset.set(offset);
843 }
844
845 pub fn set_content_y_offset(&self, offset: f32) {
848 self.refs.content_y_offset.set(offset);
849 }
850
851 fn wrap_width(&self, available_width: f32) -> Option<f32> {
858 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
859 .then_some(available_width)
860 }
861
862 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
868 let text = self.state.text();
869 let node_id = self.refs.node_id.get();
870 let annotated = crate::text::AnnotatedString::from(text.as_str());
871 let metrics = match wrap_width {
872 Some(max_width) => crate::text::measure_text_with_options_for_node(
873 node_id,
874 &annotated,
875 &self.style,
876 crate::text::TextLayoutOptions::default(),
877 Some(max_width),
878 ),
879 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
880 };
881 self.measured_line_height.set(metrics.line_height);
882 Size {
883 width: metrics.width,
884 height: metrics.height,
885 }
886 }
887
888 fn update_cached_state(&mut self) -> bool {
890 let value = self.state.value();
891 let text_changed = value.text != self.cached_text;
892 let selection_changed = value.selection != self.cached_selection;
893
894 if text_changed {
895 self.cached_text = value.text;
896 }
897 if selection_changed {
898 self.cached_selection = value.selection;
899 }
900
901 text_changed || selection_changed
902 }
903
904 pub fn position_cursor_at_offset(&self, x_offset: f32) {
907 let text = self.state.text();
908 if text.is_empty() {
909 self.state.edit(|buffer| {
910 buffer.place_cursor_at_start();
911 });
912 return;
913 }
914
915 let byte_offset = crate::text::get_offset_for_position(
918 &crate::text::AnnotatedString::from(text.as_str()),
919 &self.style,
920 x_offset + self.refs.scroll_offset.get(),
921 0.0,
922 );
923
924 self.state.edit(|buffer| {
925 buffer.place_cursor_before_char(byte_offset);
926 });
927 }
928
929 }
933
934impl DelegatableNode for TextFieldModifierNode {
935 fn node_state(&self) -> &NodeState {
936 &self.node_state
937 }
938}
939
940impl ModifierNode for TextFieldModifierNode {
941 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
942 self.refs.node_id.set(context.node_id());
944
945 context.invalidate(InvalidationKind::Layout);
946 context.invalidate(InvalidationKind::Draw);
947 context.invalidate(InvalidationKind::Semantics);
948 }
949
950 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
951 Some(self)
952 }
953
954 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
955 Some(self)
956 }
957
958 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
959 Some(self)
960 }
961
962 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
963 Some(self)
964 }
965
966 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
967 Some(self)
968 }
969
970 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
971 Some(self)
972 }
973
974 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
975 Some(self)
976 }
977
978 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
979 Some(self)
980 }
981}
982
983impl LayoutModifierNode for TextFieldModifierNode {
984 fn measure(
985 &self,
986 _context: &mut dyn ModifierNodeContext,
987 _measurable: &dyn Measurable,
988 constraints: Constraints,
989 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
990 let wrap_width = self.wrap_width(constraints.max_width);
994 self.measured_wrap_width.set(wrap_width);
997 let text_size = self.measure_text_content(wrap_width);
998
999 let min_height = if text_size.height < 1.0 {
1001 DEFAULT_LINE_HEIGHT
1002 } else {
1003 text_size.height
1004 };
1005
1006 let width = text_size
1008 .width
1009 .max(constraints.min_width)
1010 .min(constraints.max_width);
1011 let height = min_height
1012 .max(constraints.min_height)
1013 .min(constraints.max_height);
1014
1015 let size = Size { width, height };
1016 self.measured_size.set(size);
1017
1018 let _ = (self.cached_pan_resolver)(size.width);
1021
1022 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
1023 }
1024
1025 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1026 self.measure_text_content(None).width
1027 }
1028
1029 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1030 self.measure_text_content(None).width
1031 }
1032
1033 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1034 self.measure_text_content(self.wrap_width(width))
1035 .height
1036 .max(DEFAULT_LINE_HEIGHT)
1037 }
1038
1039 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1040 self.measure_text_content(self.wrap_width(width))
1041 .height
1042 .max(DEFAULT_LINE_HEIGHT)
1043 }
1044}
1045
1046fn content_viewport(
1050 measured: cranpose_ui_graphics::Size,
1051 size: cranpose_foundation::Size,
1052 padding_left: f32,
1053 padding_top: f32,
1054) -> (f32, f32) {
1055 let width = if measured.width > 0.0 {
1056 measured.width
1057 } else {
1058 (size.width - padding_left).max(0.0)
1059 };
1060 let height = if measured.height > 0.0 {
1061 measured.height
1062 } else {
1063 (size.height - padding_top).max(0.0)
1064 };
1065 (width, height)
1066}
1067
1068impl DrawModifierNode for TextFieldModifierNode {
1069 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
1070 }
1074
1075 fn create_draw_closure(
1076 &self,
1077 ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
1078 {
1079 use cranpose_ui_graphics::DrawPrimitive;
1080
1081 let is_focused = self.refs.is_focused.clone();
1083 let state = self.state.clone();
1084 let content_offset = self.refs.content_offset.clone();
1085 let content_y_offset = self.refs.content_y_offset.clone();
1086 let cursor_brush = self.cursor_brush.clone();
1087 let style = self.style.clone();
1088 let cached_line_height = self.measured_line_height.clone();
1089 let measured_size = self.measured_size.clone();
1090 let measured_wrap_width = self.measured_wrap_width.clone();
1091 let node_id = self.refs.node_id.clone();
1092 let pan_resolver = self.cached_pan_resolver.clone();
1093 let handle_controller = self.handle_controller.clone();
1094 let node_origin = self.refs.node_origin.clone();
1095 let last_pointer_source = self.refs.last_pointer_source.clone();
1096
1097 Some(Rc::new(move |size| {
1098 if !*is_focused.borrow() {
1100 if let Some(controller) = &handle_controller {
1103 controller.publish(TextFieldHandleMetrics {
1104 focused: false,
1105 touch: false,
1106 node_origin: node_origin.get(),
1107 padding_left: 0.0,
1108 padding_top: 0.0,
1109 scroll_offset: 0.0,
1110 line_height: cached_line_height.get(),
1111 glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
1112 wrap_width: measured_wrap_width.get(),
1113 });
1114 }
1115 return vec![];
1116 }
1117
1118 let mut primitives = Vec::new();
1119
1120 let text = state.text();
1121 let selection = state.selection();
1122 let padding_left = content_offset.get();
1123 let padding_top = content_y_offset.get();
1124 let line_height = cached_line_height.get();
1127
1128 let (viewport_width, viewport_height) =
1129 content_viewport(measured_size.get(), size, padding_left, padding_top);
1130 let pan = pan_resolver(viewport_width);
1132
1133 if let Some(controller) = &handle_controller {
1136 controller.publish(TextFieldHandleMetrics {
1137 focused: true,
1138 touch: last_pointer_source.get().is_touch_like(),
1139 node_origin: node_origin.get(),
1140 padding_left,
1141 padding_top,
1142 scroll_offset: pan,
1143 line_height,
1144 glyph_box: crate::text::glyph_line_box(&style, line_height),
1145 wrap_width: measured_wrap_width.get(),
1146 });
1147 }
1148 let clip_bounds = cranpose_ui_graphics::Rect {
1152 x: padding_left,
1153 y: padding_top,
1154 width: viewport_width,
1155 height: viewport_height,
1156 };
1157
1158 if let Some(comp_range) = state.composition() {
1165 let comp_start = comp_range.min();
1166 let comp_end = comp_range.max();
1167
1168 if comp_start < comp_end && comp_end <= text.len() {
1169 let underline_brush = cranpose_ui_graphics::Brush::solid(
1171 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1172 );
1173 let underline_height: f32 = 2.0;
1174
1175 for line_rect in range_visual_line_rects(
1178 &text,
1179 &style,
1180 node_id.get(),
1181 measured_wrap_width.get(),
1182 padding_left,
1183 padding_top,
1184 pan,
1185 line_height,
1186 comp_start,
1187 comp_end,
1188 ) {
1189 let underline_rect = cranpose_ui_graphics::Rect {
1190 x: line_rect.x,
1191 y: line_rect.y + line_height - underline_height,
1192 width: line_rect.width,
1193 height: underline_height,
1194 };
1195 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1196 primitives.push(DrawPrimitive::Rect {
1197 rect: clipped,
1198 brush: underline_brush.clone(),
1199 });
1200 }
1201 }
1202 }
1203 }
1204
1205 if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1211 let pos = selection.start.min(text.len());
1212 let (line_index, line_start) = caret_visual_line_for_offset(
1218 &text,
1219 &style,
1220 node_id.get(),
1221 measured_wrap_width.get(),
1222 pos,
1223 );
1224 let cursor_x = crate::text::measure_text(
1225 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1226 &style,
1227 )
1228 .width
1229 + padding_left
1230 - pan;
1231 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1234 let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1235
1236 let cursor_rect = cranpose_ui_graphics::Rect {
1237 x: cursor_x,
1238 y: cursor_y,
1239 width: CURSOR_WIDTH,
1240 height: box_h,
1241 };
1242
1243 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1244 primitives.push(DrawPrimitive::Rect {
1245 rect: clipped,
1246 brush: cursor_brush.clone(),
1247 });
1248 }
1249 }
1250
1251 primitives
1252 }))
1253 }
1254
1255 fn create_behind_draw_closure(
1256 &self,
1257 ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
1258 {
1259 use cranpose_ui_graphics::DrawPrimitive;
1260
1261 let is_focused = self.refs.is_focused.clone();
1262 let state = self.state.clone();
1263 let content_offset = self.refs.content_offset.clone();
1264 let content_y_offset = self.refs.content_y_offset.clone();
1265 let selection_brush = self.selection_brush.clone();
1266 let style = self.style.clone();
1267 let cached_line_height = self.measured_line_height.clone();
1268 let measured_size = self.measured_size.clone();
1269 let measured_wrap_width = self.measured_wrap_width.clone();
1270 let node_id = self.refs.node_id.clone();
1271 let pan_resolver = self.cached_pan_resolver.clone();
1272
1273 Some(Rc::new(move |size| {
1274 if !*is_focused.borrow() {
1275 return vec![];
1276 }
1277 let selection = state.selection();
1278 if selection.collapsed() {
1279 return vec![];
1280 }
1281 let text = state.text();
1282 let padding_left = content_offset.get();
1283 let padding_top = content_y_offset.get();
1284 let line_height = cached_line_height.get();
1285 let (viewport_width, viewport_height) =
1286 content_viewport(measured_size.get(), size, padding_left, padding_top);
1287 let pan = pan_resolver(viewport_width);
1288 let clip_bounds = cranpose_ui_graphics::Rect {
1289 x: padding_left,
1290 y: padding_top,
1291 width: viewport_width,
1292 height: viewport_height,
1293 };
1294
1295 let mut primitives = Vec::new();
1299 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1303 for sel_rect in range_visual_line_rects(
1304 &text,
1305 &style,
1306 node_id.get(),
1307 measured_wrap_width.get(),
1308 padding_left,
1309 padding_top,
1310 pan,
1311 line_height,
1312 selection.min(),
1313 selection.max(),
1314 ) {
1315 let sel_rect = cranpose_ui_graphics::Rect {
1316 y: sel_rect.y + box_off,
1317 height: box_h,
1318 ..sel_rect
1319 };
1320 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1321 primitives.push(DrawPrimitive::Rect {
1322 rect: clipped,
1323 brush: selection_brush.clone(),
1324 });
1325 }
1326 }
1327 primitives
1328 }))
1329 }
1330}
1331
1332impl SemanticsNode for TextFieldModifierNode {
1333 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1334 let text = self.state.text();
1335 config.content_description = Some(text);
1336 config.is_editable_text = true;
1337 config.text_selection = Some(self.state.selection());
1338 }
1339}
1340
1341impl PointerInputNode for TextFieldModifierNode {
1342 fn on_pointer_event(
1343 &mut self,
1344 _context: &mut dyn ModifierNodeContext,
1345 _event: &PointerEvent,
1346 ) -> bool {
1347 false
1358 }
1359
1360 fn hit_test(&self, x: f32, y: f32) -> bool {
1361 let size = self.measured_size.get();
1363 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1364 }
1365
1366 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1367 Some(self.cached_handler.clone())
1369 }
1370}
1371
1372#[derive(Clone)]
1383pub struct TextFieldElement {
1384 state: TextFieldState,
1386 style: TextStyle,
1388 cursor_color: Color,
1390 line_limits: TextFieldLineLimits,
1392 handle_controller: Option<TextFieldHandleController>,
1395}
1396
1397impl TextFieldElement {
1398 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1400 Self {
1401 state,
1402 style,
1403 cursor_color: DEFAULT_CURSOR_COLOR,
1404 line_limits: TextFieldLineLimits::default(),
1405 handle_controller: None,
1406 }
1407 }
1408
1409 pub fn with_cursor_color(mut self, color: Color) -> Self {
1411 self.cursor_color = color;
1412 self
1413 }
1414
1415 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1417 self.line_limits = line_limits;
1418 self
1419 }
1420
1421 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1423 self.handle_controller = Some(controller);
1424 self
1425 }
1426}
1427
1428impl std::fmt::Debug for TextFieldElement {
1429 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1430 f.debug_struct("TextFieldElement")
1431 .field("text", &self.state.text())
1432 .field("style", &self.style)
1433 .field("cursor_color", &self.cursor_color)
1434 .finish()
1435 }
1436}
1437
1438impl Hash for TextFieldElement {
1439 fn hash<H: Hasher>(&self, state: &mut H) {
1440 std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1443 self.cursor_color.0.to_bits().hash(state);
1445 self.cursor_color.1.to_bits().hash(state);
1446 self.cursor_color.2.to_bits().hash(state);
1447 self.cursor_color.3.to_bits().hash(state);
1448 self.style.render_hash().hash(state);
1449 self.line_limits.hash(state);
1450 }
1451}
1452
1453impl PartialEq for TextFieldElement {
1454 fn eq(&self, other: &Self) -> bool {
1455 self.state == other.state
1459 && self.style == other.style
1460 && self.cursor_color == other.cursor_color
1461 && self.line_limits == other.line_limits
1462 }
1463}
1464
1465impl Eq for TextFieldElement {}
1466
1467impl ModifierNodeElement for TextFieldElement {
1468 type Node = TextFieldModifierNode;
1469
1470 fn create(&self) -> Self::Node {
1471 let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1472 .with_cursor_color(self.cursor_color)
1473 .with_line_limits(self.line_limits);
1474 if let Some(controller) = self.handle_controller.clone() {
1475 node = node.with_handle_controller(controller);
1476 }
1477 node
1478 }
1479
1480 fn update(&self, node: &mut Self::Node) {
1481 node.state = self.state.clone();
1483 node.style = self.style.clone();
1484 node.cursor_brush = Brush::solid(self.cursor_color);
1485 node.line_limits = self.line_limits;
1486 node.handle_controller = self.handle_controller.clone();
1487
1488 node.cached_handler = TextFieldModifierNode::create_handler(
1490 node.state.clone(),
1491 node.refs.clone(),
1492 node.line_limits,
1493 self.style.clone(),
1494 );
1495
1496 node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1498 node.state.clone(),
1499 node.refs.clone(),
1500 node.line_limits,
1501 self.style.clone(),
1502 );
1503
1504 if node.update_cached_state() {
1506 }
1509 }
1510
1511 fn capabilities(&self) -> NodeCapabilities {
1512 NodeCapabilities::LAYOUT
1513 | NodeCapabilities::DRAW
1514 | NodeCapabilities::SEMANTICS
1515 | NodeCapabilities::POINTER_INPUT
1516 }
1517
1518 fn always_update(&self) -> bool {
1519 true
1521 }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526 use super::*;
1527 use crate::text::TextStyle;
1528 use cranpose_core::{DefaultScheduler, Runtime};
1529 use std::sync::Arc;
1530
1531 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1533 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1534 f()
1535 }
1536
1537 #[test]
1538 fn text_field_node_creation() {
1539 let _app_context = crate::render_state::app_context_test_scope();
1540 with_test_runtime(|| {
1541 let state = TextFieldState::new("Hello");
1542 let node = TextFieldModifierNode::new(state, TextStyle::default());
1543 assert_eq!(node.text(), "Hello");
1544 assert!(!node.is_focused());
1545 });
1546 }
1547
1548 #[test]
1553 fn selection_rects_follow_wrapped_visual_lines() {
1554 let _app_context = crate::render_state::app_context_test_scope();
1555 let text = "aaaaa\nbb";
1558 let style = TextStyle::default();
1559 let line_height = 10.0_f32;
1560
1561 let rects = range_visual_line_rects(
1564 text,
1565 &style,
1566 None,
1567 Some(30.0),
1568 0.0,
1569 0.0,
1570 0.0,
1571 line_height,
1572 6,
1573 8,
1574 );
1575 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1576 assert_eq!(
1577 rects[0].y,
1578 2.0 * line_height,
1579 "highlight must land on visual line 2, not logical line 1"
1580 );
1581 assert!(rects[0].width > 0.0);
1582
1583 let spanning = range_visual_line_rects(
1586 text,
1587 &style,
1588 None,
1589 Some(30.0),
1590 0.0,
1591 0.0,
1592 0.0,
1593 line_height,
1594 0,
1595 5,
1596 );
1597 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1598 assert_eq!(spanning[0].y, 0.0);
1599 assert_eq!(spanning[1].y, line_height);
1600 }
1601
1602 #[test]
1607 fn tap_resolves_offset_on_wrapped_visual_line() {
1608 let _app_context = crate::render_state::app_context_test_scope();
1609 let text = "aaaaa\nbb";
1612 let style = TextStyle::default();
1613 let line_height = 10.0_f32;
1614
1615 let off = crate::text::offset_for_position_wrapped(
1618 text,
1619 &style,
1620 None,
1621 Some(30.0),
1622 line_height,
1623 8.0,
1624 22.0,
1625 );
1626 assert!(
1627 (6..=8).contains(&off),
1628 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1629 );
1630
1631 let off1 = crate::text::offset_for_position_wrapped(
1634 text,
1635 &style,
1636 None,
1637 Some(30.0),
1638 line_height,
1639 4.0,
1640 12.0,
1641 );
1642 assert!(
1643 (3..=5).contains(&off1),
1644 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1645 );
1646
1647 let off2 = crate::text::offset_for_position_wrapped(
1649 "hello",
1650 &style,
1651 None,
1652 None,
1653 line_height,
1654 0.0,
1655 0.0,
1656 );
1657 assert_eq!(off2, 0);
1658 }
1659
1660 #[test]
1661 fn text_field_node_focus() {
1662 let _app_context = crate::render_state::app_context_test_scope();
1663 with_test_runtime(|| {
1664 let state = TextFieldState::new("Test");
1665 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1666 assert!(!node.is_focused());
1667
1668 node.set_focused(true);
1669 assert!(node.is_focused());
1670
1671 node.set_focused(false);
1672 assert!(!node.is_focused());
1673 });
1674 }
1675
1676 #[test]
1677 fn text_field_element_creates_node() {
1678 let _app_context = crate::render_state::app_context_test_scope();
1679 with_test_runtime(|| {
1680 let state = TextFieldState::new("Hello World");
1681 let element = TextFieldElement::new(state, TextStyle::default());
1682
1683 let node = element.create();
1684 assert_eq!(node.text(), "Hello World");
1685 });
1686 }
1687
1688 #[test]
1697 fn touch_press_publishes_touch_handle_metrics_but_mouse_does_not() {
1698 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1699 use cranpose_ui_graphics::Point;
1700
1701 let _app_context = crate::render_state::app_context_test_scope();
1702 with_test_runtime(|| {
1703 let state = TextFieldState::new("hello world");
1704 let controller = TextFieldHandleController::new();
1705 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1706 .with_handle_controller(controller.clone());
1707 node.measured_size.set(Size {
1709 width: 120.0,
1710 height: 20.0,
1711 });
1712
1713 let handler = node
1714 .pointer_input_handler()
1715 .expect("field exposes a pointer handler");
1716 let draw = node
1717 .create_draw_closure()
1718 .expect("field exposes a draw closure");
1719 let at = Point { x: 12.0, y: 8.0 };
1720 let size = Size {
1721 width: 120.0,
1722 height: 20.0,
1723 };
1724
1725 handler(
1728 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1729 );
1730 let _ = draw(size);
1731 let metrics = controller
1732 .metrics()
1733 .expect("focused field publishes handle metrics");
1734 assert!(metrics.focused, "a tap focuses the field");
1735 assert!(
1736 metrics.touch,
1737 "a touch tap must publish touch = true so the finger handles show"
1738 );
1739
1740 handler(
1743 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1744 );
1745 let _ = draw(size);
1746 let metrics = controller
1747 .metrics()
1748 .expect("focused field publishes handle metrics");
1749 assert!(
1750 !metrics.touch,
1751 "a mouse tap must publish touch = false (clean caret, no finger handle)"
1752 );
1753
1754 crate::text_field_focus::clear_focus();
1755 });
1756 }
1757
1758 #[test]
1766 fn double_tap_selects_the_word_under_the_finger() {
1767 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1768 use cranpose_ui_graphics::Point;
1769
1770 let _app_context = crate::render_state::app_context_test_scope();
1771 with_test_runtime(|| {
1772 let state = TextFieldState::new("hello world");
1773 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1774 node.measured_size.set(Size {
1775 width: 200.0,
1776 height: 20.0,
1777 });
1778 let handler = node
1779 .pointer_input_handler()
1780 .expect("field exposes a pointer handler");
1781
1782 let at = Point { x: 2.0, y: 8.0 };
1785 handler(
1786 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1787 );
1788 handler(
1789 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1790 );
1791
1792 let selection = state.selection();
1793 assert!(
1794 !selection.collapsed(),
1795 "a double tap must produce a (word) selection, got {selection:?}"
1796 );
1797 let selected = &state.text()[selection.min()..selection.max()];
1798 assert_eq!(
1799 selected, "hello",
1800 "double tap should select the whole word under the finger"
1801 );
1802
1803 crate::text_field_focus::clear_focus();
1804 });
1805 }
1806
1807 #[test]
1811 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1812 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1813 use cranpose_ui_graphics::Point;
1814
1815 let _app_context = crate::render_state::app_context_test_scope();
1816 with_test_runtime(|| {
1817 let text = "alpha beta\ngamma delta\n\nsecond para";
1820 let state = TextFieldState::new(text);
1821 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1822 .with_line_limits(TextFieldLineLimits::MultiLine {
1823 min_lines: 1,
1824 max_lines: usize::MAX,
1825 });
1826 node.measured_size.set(Size {
1827 width: 400.0,
1828 height: 80.0,
1829 });
1830 let handler = node
1831 .pointer_input_handler()
1832 .expect("field exposes a pointer handler");
1833
1834 let at = Point { x: 2.0, y: 4.0 };
1836 let tap = || {
1837 handler(
1838 PointerEvent::new(PointerEventKind::Down, at, at)
1839 .with_source(PointerSource::Touch),
1840 );
1841 };
1842 let selected = |state: &TextFieldState| {
1843 let s = state.selection();
1844 state.text()[s.min()..s.max()].to_string()
1845 };
1846
1847 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1849 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1851 tap(); assert_eq!(
1853 selected(&state),
1854 "alpha beta",
1855 "triple tap selects the line"
1856 );
1857 tap(); assert_eq!(
1859 selected(&state),
1860 "alpha beta\ngamma delta",
1861 "fourth tap grows to the paragraph"
1862 );
1863 tap(); assert_eq!(
1865 selected(&state),
1866 "alpha",
1867 "fifth tap cycles back to the word"
1868 );
1869
1870 crate::text_field_focus::clear_focus();
1871 });
1872 }
1873
1874 #[test]
1878 fn single_tap_inside_selection_selects_the_word() {
1879 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1880 use cranpose_ui_graphics::Point;
1881
1882 let _app_context = crate::render_state::app_context_test_scope();
1883 with_test_runtime(|| {
1884 let state = TextFieldState::new("hello world");
1885 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1886 node.measured_size.set(Size {
1887 width: 200.0,
1888 height: 20.0,
1889 });
1890 let handler = node
1891 .pointer_input_handler()
1892 .expect("field exposes a pointer handler");
1893
1894 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1896 assert!(!state.selection().collapsed());
1897
1898 let at = Point { x: 2.0, y: 8.0 };
1901 handler(
1902 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1903 );
1904
1905 let selection = state.selection();
1906 assert!(
1907 !selection.collapsed(),
1908 "a tap inside a selection must not collapse it, got {selection:?}"
1909 );
1910 assert_eq!(
1911 &state.text()[selection.min()..selection.max()],
1912 "hello",
1913 "a tap inside a selection re-selects the word under the finger"
1914 );
1915
1916 crate::text_field_focus::clear_focus();
1917 });
1918 }
1919
1920 #[test]
1928 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
1929 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1930 use cranpose_ui_graphics::Point;
1931
1932 let _app_context = crate::render_state::app_context_test_scope();
1933 with_test_runtime(|| {
1934 let text = "alpha beta\ngamma delta\n\nsecond para";
1935 let state = TextFieldState::new(text);
1936 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1937 .with_line_limits(TextFieldLineLimits::MultiLine {
1938 min_lines: 1,
1939 max_lines: usize::MAX,
1940 });
1941 node.measured_size.set(Size {
1942 width: 400.0,
1943 height: 80.0,
1944 });
1945 let handler = node
1946 .pointer_input_handler()
1947 .expect("field exposes a pointer handler");
1948
1949 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
1951
1952 let at = Point { x: 2.0, y: 4.0 };
1953 let selected = |state: &TextFieldState| {
1954 let s = state.selection();
1955 state.text()[s.min()..s.max()].to_string()
1956 };
1957 let slow_tap = || {
1960 node.refs.last_click_time.set(None);
1961 handler(
1962 PointerEvent::new(PointerEventKind::Down, at, at)
1963 .with_source(PointerSource::Touch),
1964 );
1965 };
1966
1967 slow_tap(); assert_eq!(
1969 selected(&state),
1970 "alpha",
1971 "tap inside selection grabs the word"
1972 );
1973 slow_tap(); assert_eq!(
1975 selected(&state),
1976 "alpha beta",
1977 "same-spot tap grows to the line even after the timeout"
1978 );
1979 slow_tap(); assert_eq!(
1981 selected(&state),
1982 "alpha beta\ngamma delta",
1983 "same-spot tap grows to the paragraph"
1984 );
1985 slow_tap(); assert_eq!(
1987 selected(&state),
1988 "alpha",
1989 "same-spot tap cycles back to the word"
1990 );
1991
1992 crate::text_field_focus::clear_focus();
1993 });
1994 }
1995
1996 #[test]
1997 fn text_field_element_equality() {
1998 let _app_context = crate::render_state::app_context_test_scope();
1999 with_test_runtime(|| {
2000 let state1 = TextFieldState::new("Hello");
2001 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
2004 let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
2010 assert_ne!(elem1, elem3, "Different states should not be equal");
2011 });
2012 }
2013
2014 #[test]
2015 fn text_field_element_update_refreshes_existing_node_style() {
2016 let _app_context = crate::render_state::app_context_test_scope();
2017 with_test_runtime(|| {
2018 let state = TextFieldState::new("themed text");
2019 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
2020 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
2021 ..crate::text::SpanStyle::default()
2022 });
2023 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
2024 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
2025 ..crate::text::SpanStyle::default()
2026 });
2027 let initial = TextFieldElement::new(state.clone(), dark_style);
2028 let updated = TextFieldElement::new(state, light_style.clone());
2029 let mut node = initial.create();
2030
2031 updated.update(&mut node);
2032
2033 assert_eq!(node.text(), "themed text");
2034 assert_eq!(node.style(), &light_style);
2035 });
2036 }
2037
2038 #[test]
2043 fn multiline_field_measures_wrapped_height() {
2044 let _app_context = crate::render_state::app_context_test_scope();
2045 with_test_runtime(|| {
2046 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
2048 let node = TextFieldModifierNode::new(state, TextStyle::default());
2049 assert!(
2050 !node.line_limits().is_single_line(),
2051 "default fields are multi-line"
2052 );
2053
2054 let natural = node.measure_text_content(None);
2055 let wrapped = node.measure_text_content(node.wrap_width(20.0));
2056
2057 assert!(
2058 wrapped.height > natural.height,
2059 "wrapped multi-line height {} must exceed the single-line height {}",
2060 wrapped.height,
2061 natural.height
2062 );
2063 });
2064 }
2065
2066 #[test]
2069 fn single_line_field_never_wraps() {
2070 let _app_context = crate::render_state::app_context_test_scope();
2071 with_test_runtime(|| {
2072 let state = TextFieldState::new("abcd ".repeat(40));
2073 let node = TextFieldModifierNode::new(state, TextStyle::default())
2074 .with_line_limits(TextFieldLineLimits::SingleLine);
2075 assert_eq!(
2076 node.wrap_width(20.0),
2077 None,
2078 "single-line fields must not wrap"
2079 );
2080 });
2081 }
2082
2083 #[test]
2089 fn test_cursor_x_position_calculation() {
2090 let _app_context = crate::render_state::app_context_test_scope();
2091 with_test_runtime(|| {
2092 let style = crate::text::TextStyle::default();
2094
2095 let empty_width =
2097 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2098 assert!(
2099 empty_width.abs() < 0.1,
2100 "Empty text should have 0 width, got {}",
2101 empty_width
2102 );
2103
2104 let hi_width =
2106 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2107 assert!(
2108 hi_width > 0.0,
2109 "Text 'Hi' should have positive width: {}",
2110 hi_width
2111 );
2112
2113 let h_width =
2115 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2116 assert!(h_width > 0.0, "Text 'H' should have positive width");
2117 assert!(
2118 h_width < hi_width,
2119 "'H' width {} should be less than 'Hi' width {}",
2120 h_width,
2121 hi_width
2122 );
2123
2124 let state = TextFieldState::new("Hi");
2126 assert_eq!(
2127 state.selection().start,
2128 2,
2129 "Cursor should be at position 2 (end of 'Hi')"
2130 );
2131
2132 let text = state.text();
2134 let cursor_pos = state.selection().start;
2135 let text_before_cursor = &text[..cursor_pos.min(text.len())];
2136 assert_eq!(text_before_cursor, "Hi");
2137
2138 let cursor_x = crate::text::measure_text(
2140 &crate::text::AnnotatedString::from(text_before_cursor),
2141 &style,
2142 )
2143 .width;
2144 assert!(
2145 (cursor_x - hi_width).abs() < 0.1,
2146 "Cursor x {} should equal 'Hi' width {}",
2147 cursor_x,
2148 hi_width
2149 );
2150 });
2151 }
2152
2153 #[test]
2155 fn test_focused_node_creates_cursor() {
2156 let _app_context = crate::render_state::app_context_test_scope();
2157 with_test_runtime(|| {
2158 let state = TextFieldState::new("Test");
2159 let element = TextFieldElement::new(state.clone(), TextStyle::default());
2160 let node = element.create();
2161
2162 assert!(!node.is_focused());
2164
2165 *node.refs.is_focused.borrow_mut() = true;
2167 assert!(node.is_focused());
2168
2169 assert_eq!(node.text(), "Test");
2171
2172 assert_eq!(node.selection().start, 4);
2174 });
2175 }
2176}