1use cranpose_core::{mutableStateOf, MutableState};
20use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
21use cranpose_foundation::{
22 Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
23 LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
24 NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode,
25 SemanticsConfiguration, SemanticsNode, Size,
26};
27use cranpose_ui_graphics::{Brush, Color, Point};
28use std::cell::{Cell, RefCell};
29use std::hash::{Hash, Hasher};
30use std::rc::Rc;
31
32#[derive(Clone, Copy, PartialEq, Debug)]
37pub struct TextFieldHandleMetrics {
38 pub focused: bool,
39 pub direct_manipulation: bool,
42 pub node_origin: Point,
44 pub padding_left: f32,
45 pub padding_top: f32,
46 pub scroll_offset: f32,
47 pub line_height: f32,
48 pub glyph_box: (f32, f32),
52 pub wrap_width: Option<f32>,
55 pub press: Option<PointerPressTrack>,
58}
59
60#[derive(Clone)]
65pub struct TextFieldHandleController {
66 inner: Rc<TextFieldHandleControllerInner>,
67}
68
69impl PartialEq for TextFieldHandleController {
70 fn eq(&self, other: &Self) -> bool {
71 Rc::ptr_eq(&self.inner, &other.inner)
72 }
73}
74
75struct TextFieldHandleControllerInner {
76 metrics: Cell<Option<TextFieldHandleMetrics>>,
77 revision: MutableState<u64>,
78 gesture_claim: RefCell<Option<Rc<Cell<bool>>>>,
82}
83
84impl TextFieldHandleController {
85 pub fn new() -> Self {
88 Self {
89 inner: Rc::new(TextFieldHandleControllerInner {
90 metrics: Cell::new(None),
91 revision: mutableStateOf(0u64),
92 gesture_claim: RefCell::new(None),
93 }),
94 }
95 }
96
97 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
100 if self.inner.metrics.get() != Some(metrics) {
101 self.inner.metrics.set(Some(metrics));
102 self.inner
103 .revision
104 .update(|value| *value = value.wrapping_add(1));
105 }
106 }
107
108 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
111 let _ = self.inner.revision.value();
112 self.inner.metrics.get()
113 }
114
115 pub(crate) fn adopt_gesture_claim(&self, claim: &Rc<Cell<bool>>) {
117 let mut slot = self.inner.gesture_claim.borrow_mut();
118 let adopted = slot.as_ref().is_some_and(|held| Rc::ptr_eq(held, claim));
119 if !adopted {
120 *slot = Some(Rc::clone(claim));
121 }
122 }
123
124 pub fn claim_gesture(&self) {
127 if let Some(claim) = self.inner.gesture_claim.borrow().as_ref() {
128 claim.set(true);
129 }
130 }
131
132 pub fn gesture_claimed(&self) -> bool {
134 self.inner
135 .gesture_claim
136 .borrow()
137 .as_ref()
138 .is_some_and(|claim| claim.get())
139 }
140}
141
142impl Default for TextFieldHandleController {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
150
151const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
153
154const DEFAULT_LINE_HEIGHT: f32 = 20.0;
156
157const CURSOR_WIDTH: f32 = 2.0;
159
160pub(crate) fn compute_horizontal_scroll_offset(
171 current_offset: f32,
172 cursor_x: f32,
173 text_width: f32,
174 viewport_width: f32,
175) -> f32 {
176 if viewport_width <= 0.0 {
177 return 0.0;
178 }
179 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
180 let mut offset = current_offset.clamp(0.0, max_offset);
181 let visible_end = offset + viewport_width - CURSOR_WIDTH;
182 if cursor_x > visible_end {
183 offset = cursor_x - viewport_width + CURSOR_WIDTH;
185 } else if cursor_x < offset {
186 offset = cursor_x;
188 }
189 offset.clamp(0.0, max_offset)
190}
191
192pub(crate) fn intersect_rect(
197 rect: cranpose_ui_graphics::Rect,
198 bounds: cranpose_ui_graphics::Rect,
199) -> Option<cranpose_ui_graphics::Rect> {
200 let x0 = rect.x.max(bounds.x);
201 let y0 = rect.y.max(bounds.y);
202 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
203 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
204 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
205 x: x0,
206 y: y0,
207 width: x1 - x0,
208 height: y1 - y0,
209 })
210}
211
212pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
215
216pub(crate) fn caret_visual_line_for_offset(
226 text: &str,
227 style: &TextStyle,
228 node_id: Option<cranpose_core::NodeId>,
229 wrap_width: Option<f32>,
230 offset: usize,
231 affinity: crate::text_selection::LineAffinity,
232) -> (usize, usize) {
233 let offset = offset.min(text.len());
234 match wrap_width {
235 Some(width) if width.is_finite() && width > 0.0 => {
236 let annotated = crate::text::AnnotatedString::from(text);
237 let ranges = crate::text::wrapped_line_ranges(
238 node_id,
239 &annotated,
240 style,
241 crate::text::TextLayoutOptions::default(),
242 Some(width),
243 );
244 crate::text_selection::caret_visual_line(&ranges, offset, affinity)
245 }
246 _ => {
247 let before = &text[..offset];
250 let line_index = before.matches('\n').count();
251 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
252 (line_index, line_start)
253 }
254 }
255}
256
257#[allow(clippy::too_many_arguments)]
268pub(crate) fn range_visual_line_rects(
269 text: &str,
270 style: &TextStyle,
271 node_id: Option<cranpose_core::NodeId>,
272 wrap_width: Option<f32>,
273 padding_left: f32,
274 padding_top: f32,
275 pan: f32,
276 line_height: f32,
277 start: usize,
278 end: usize,
279) -> Vec<cranpose_ui_graphics::Rect> {
280 if start >= end {
281 return Vec::new();
282 }
283 let annotated = crate::text::AnnotatedString::from(text);
284 let line_ranges = crate::text::wrapped_line_ranges(
285 node_id,
286 &annotated,
287 style,
288 crate::text::TextLayoutOptions::default(),
289 wrap_width,
290 );
291 let mut rects = Vec::new();
292 for (line_idx, line_range) in line_ranges.iter().enumerate() {
293 let line_start = line_range.start;
294 let line_end = line_range.end;
295 if end <= line_start || start >= line_end {
296 continue;
297 }
298 let seg_start = start.max(line_start);
299 let seg_end = end.min(line_end);
300 let x0 = crate::text::measure_text(
301 &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
302 style,
303 )
304 .width
305 + padding_left
306 - pan;
307 let x1 = crate::text::measure_text(
308 &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
309 style,
310 )
311 .width
312 + padding_left
313 - pan;
314 let width = x1 - x0;
315 if width > 0.0 {
316 rects.push(cranpose_ui_graphics::Rect {
317 x: x0,
318 y: padding_top + line_idx as f32 * line_height,
319 width,
320 height: line_height,
321 });
322 }
323 }
324 rects
325}
326
327#[derive(Clone)]
333pub(crate) struct TextFieldRefs {
334 pub is_focused: Rc<RefCell<bool>>,
336 pub content_offset: Rc<Cell<f32>>,
338 pub content_y_offset: Rc<Cell<f32>>,
340 pub drag_anchor: Rc<Cell<Option<usize>>>,
342 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
344 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
346 pub click_count: Rc<Cell<u8>>,
348 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
350 pub scroll_offset: Rc<Cell<f32>>,
353 pub direct_manipulation: Rc<Cell<bool>>,
357 pub node_origin: Rc<Cell<Point>>,
361 pub line_height: Rc<Cell<f32>>,
365 pub wrap_width: Rc<Cell<Option<f32>>>,
369 pub press_track: Rc<Cell<Option<PointerPressTrack>>>,
372 pub gesture_claimed: Rc<Cell<bool>>,
376}
377
378#[derive(Clone, Copy, Debug, PartialEq)]
381pub struct PointerPressTrack {
382 pub start: Point,
384 pub position: Point,
386}
387
388impl TextFieldRefs {
389 pub fn new() -> Self {
391 Self {
392 is_focused: Rc::new(RefCell::new(false)),
393 content_offset: Rc::new(Cell::new(0.0_f32)),
394 content_y_offset: Rc::new(Cell::new(0.0_f32)),
395 drag_anchor: Rc::new(Cell::new(None::<usize>)),
396 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
397 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
398 click_count: Rc::new(Cell::new(0_u8)),
399 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
400 scroll_offset: Rc::new(Cell::new(0.0_f32)),
401 direct_manipulation: Rc::new(Cell::new(false)),
402 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
403 line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
404 wrap_width: Rc::new(Cell::new(None::<f32>)),
405 press_track: Rc::new(Cell::new(None::<PointerPressTrack>)),
406 gesture_claimed: Rc::new(Cell::new(false)),
407 }
408 }
409}
410
411use crate::text::TextStyle; pub struct TextFieldModifierNode {
420 state: TextFieldState,
422 refs: TextFieldRefs,
424 style: TextStyle, cursor_brush: Brush,
428 selection_brush: Brush,
430 line_limits: TextFieldLineLimits,
432 cached_text: String,
434 cached_selection: TextRange,
436 node_state: NodeState,
438 measured_size: Rc<Cell<Size>>,
440 measured_line_height: Rc<Cell<f32>>,
442 measured_wrap_width: Rc<Cell<Option<f32>>>,
448 cached_handler: Rc<dyn Fn(PointerEvent)>,
450 cached_pan_resolver: TextPanResolver,
452 handle_controller: Option<TextFieldHandleController>,
456}
457
458impl std::fmt::Debug for TextFieldModifierNode {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 f.debug_struct("TextFieldModifierNode")
461 .field("text", &self.state.text())
462 .field("style", &self.style)
463 .field("is_focused", &*self.refs.is_focused.borrow())
464 .finish()
465 }
466}
467
468use crate::text_field_handler::TextFieldHandler;
470
471impl TextFieldModifierNode {
472 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
474 let value = state.value();
475 let refs = TextFieldRefs::new();
476 let refs_line_height = refs.line_height.clone();
477 let refs_wrap_width = refs.wrap_width.clone();
478 let line_limits = TextFieldLineLimits::default();
479 let cached_handler = Self::create_handler(state, refs.clone(), line_limits, style.clone());
480 let cached_pan_resolver =
481 Self::create_pan_resolver(state, refs.clone(), line_limits, style.clone());
482
483 Self {
484 state,
485 refs,
486 style,
487 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
488 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
489 line_limits,
490 cached_text: value.text,
491 cached_selection: value.selection,
492 node_state: NodeState::new(),
493 measured_size: Rc::new(Cell::new(Size {
494 width: 0.0,
495 height: 0.0,
496 })),
497 measured_line_height: refs_line_height,
501 measured_wrap_width: refs_wrap_width,
502 cached_handler,
503 cached_pan_resolver,
504 handle_controller: None,
505 }
506 }
507
508 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
510 self.line_limits = line_limits;
511 self.cached_pan_resolver = Self::create_pan_resolver(
512 self.state,
513 self.refs.clone(),
514 line_limits,
515 self.style.clone(),
516 );
517 self
518 }
519
520 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
522 self.handle_controller = Some(controller);
523 self
524 }
525
526 fn create_pan_resolver(
534 state: TextFieldState,
535 refs: TextFieldRefs,
536 line_limits: TextFieldLineLimits,
537 style: TextStyle,
538 ) -> TextPanResolver {
539 Rc::new(move |viewport_width: f32| {
540 if !line_limits.is_single_line() {
541 refs.scroll_offset.set(0.0);
543 return 0.0;
544 }
545 let text = state.text();
546 let pos = state.selection().start.min(text.len());
547 let text_width = crate::text::measure_text(
548 &crate::text::AnnotatedString::from(text.as_str()),
549 &style,
550 )
551 .width;
552 let cursor_x = crate::text::measure_text(
553 &crate::text::AnnotatedString::from(&text[..pos]),
554 &style,
555 )
556 .width;
557 let offset = compute_horizontal_scroll_offset(
558 refs.scroll_offset.get(),
559 cursor_x,
560 text_width,
561 viewport_width,
562 );
563 refs.scroll_offset.set(offset);
564 offset
565 })
566 }
567
568 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
573 self.line_limits
574 .is_single_line()
575 .then(|| self.cached_pan_resolver.clone())
576 }
577
578 pub fn scroll_offset(&self) -> f32 {
580 self.refs.scroll_offset.get()
581 }
582
583 pub fn line_limits(&self) -> TextFieldLineLimits {
585 self.line_limits
586 }
587
588 fn create_handler(
590 state: TextFieldState,
591 refs: TextFieldRefs,
592 line_limits: TextFieldLineLimits,
593 style: TextStyle, ) -> Rc<dyn Fn(PointerEvent)> {
595 use crate::text_selection::{
598 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
599 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
600 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
601 };
602 use crate::word_boundaries::find_word_boundaries;
603
604 Rc::new(move |event: PointerEvent| {
605 refs.node_origin.set(Point {
612 x: event.global_position.x - event.position.x,
613 y: event.global_position.y - event.position.y,
614 });
615
616 let click_x =
620 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
621 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
622
623 match event.kind {
624 PointerEventKind::Down => {
625 refs.direct_manipulation.set(true);
629 refs.press_track.set(Some(PointerPressTrack {
630 start: event.global_position,
631 position: event.global_position,
632 }));
633 refs.gesture_claimed.set(false);
634
635 let handler = TextFieldHandler::new(
640 state,
641 refs.node_id.get(),
642 line_limits,
643 crate::text_field_handler::CaretGeometryRefs {
644 node_origin: refs.node_origin.clone(),
645 content_offset: refs.content_offset.clone(),
646 content_y_offset: refs.content_y_offset.clone(),
647 scroll_offset: refs.scroll_offset.clone(),
648 style: style.clone(),
649 },
650 );
651 crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
652
653 let now = web_time::Instant::now();
654 let text = state.text();
655 let pos = crate::text::offset_for_position_wrapped(
656 &text,
657 &style,
658 refs.node_id.get(),
659 refs.wrap_width.get(),
660 refs.line_height.get(),
661 click_x,
662 click_y,
663 );
664
665 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
670 let count = refs.click_count.get();
671 (count > 0).then_some((count, px, py))
672 });
673 let elapsed_ms = refs
674 .last_click_time
675 .get()
676 .map(|last| now.duration_since(last).as_millis())
677 .unwrap_or(u128::MAX);
678 let tap_count = classify_tap_count(
679 previous,
680 elapsed_ms,
681 event.position.x,
682 event.position.y,
683 MULTI_TAP_TIMEOUT_MS,
684 MULTI_TAP_SLOP_PX,
685 );
686
687 let selection = state.selection();
695 let tap_in_selection =
696 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
697 let repeat_in_place = refs
700 .last_click_pos
701 .get()
702 .map(|(px, py)| {
703 let dx = event.position.x - px;
704 let dy = event.position.y - py;
705 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
706 })
707 .unwrap_or(false);
708 let effective_count = resolve_selection_tap_count(
709 tap_count,
710 refs.click_count.get(),
711 tap_in_selection,
712 repeat_in_place,
713 );
714
715 match tap_selection_granularity(effective_count) {
716 SelectionGranularity::Paragraph => {
717 let (start, end) = find_paragraph_boundaries(&text, pos);
719 state.edit(|buffer| {
720 buffer.select(TextRange::new(start, end));
721 });
722 refs.drag_anchor.set(Some(start));
723 }
724 SelectionGranularity::Line => {
725 let (line_start, line_end) = find_line_boundaries(&text, pos);
727 state.edit(|buffer| {
728 buffer.select(TextRange::new(line_start, line_end));
729 });
730 refs.drag_anchor.set(Some(line_start));
731 }
732 SelectionGranularity::Word => {
733 let (word_start, word_end) = find_word_boundaries(&text, pos);
736 state.edit(|buffer| {
737 buffer.select(TextRange::new(word_start, word_end));
738 });
739 refs.drag_anchor.set(Some(word_start));
740 }
741 SelectionGranularity::Caret => {
742 refs.drag_anchor.set(Some(pos));
744 state.edit(|buffer| {
745 buffer.place_cursor_before_char(pos);
746 });
747 }
748 }
749
750 refs.click_count.set(effective_count);
751 refs.last_click_time.set(Some(now));
752 refs.last_click_pos
753 .set(Some((event.position.x, event.position.y)));
754 event.consume();
755 }
756 PointerEventKind::Move => {
757 if let Some(mut track) = refs.press_track.get() {
759 track.position = event.global_position;
760 refs.press_track.set(Some(track));
761 crate::request_render_invalidation();
762 }
763 if refs.gesture_claimed.get() {
766 event.consume();
767 return;
768 }
769 if let Some(anchor) = refs.drag_anchor.get() {
771 if *refs.is_focused.borrow() {
772 let text = state.text();
773 let current_pos = crate::text::offset_for_position_wrapped(
774 &text,
775 &style,
776 refs.node_id.get(),
777 refs.wrap_width.get(),
778 refs.line_height.get(),
779 click_x,
780 click_y,
781 );
782
783 state.set_selection(TextRange::new(anchor, current_pos));
785
786 crate::request_render_invalidation();
788
789 event.consume();
790 }
791 }
792 }
793 PointerEventKind::Up => {
794 refs.drag_anchor.set(None);
796 refs.press_track.set(None);
797 refs.gesture_claimed.set(false);
798 }
799 PointerEventKind::Cancel => {
800 refs.press_track.set(None);
801 refs.gesture_claimed.set(false);
802 }
803 _ => {}
804 }
805 })
806 }
807
808 pub fn with_cursor_color(mut self, color: Color) -> Self {
813 self.cursor_brush = Brush::solid(color);
814 self.selection_brush = Brush::solid(
815 color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
816 );
817 self
818 }
819
820 pub fn set_focused(&mut self, focused: bool) {
822 let current = *self.refs.is_focused.borrow();
823 if current != focused {
824 *self.refs.is_focused.borrow_mut() = focused;
825 if !focused {
826 self.refs.direct_manipulation.set(false);
827 self.refs.press_track.set(None);
828 self.refs.gesture_claimed.set(false);
829 }
830 }
831 }
832
833 pub fn is_focused(&self) -> bool {
835 *self.refs.is_focused.borrow()
836 }
837
838 pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
840 self.refs.is_focused.clone()
841 }
842
843 pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
845 self.refs.content_offset.clone()
846 }
847
848 pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
850 self.refs.content_y_offset.clone()
851 }
852
853 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
866 self.refs.node_origin.clone()
867 }
868
869 pub fn text(&self) -> String {
871 self.state.text()
872 }
873
874 pub fn style(&self) -> &TextStyle {
875 &self.style
876 }
877
878 pub fn selection(&self) -> TextRange {
880 self.state.selection()
881 }
882
883 pub fn cursor_brush(&self) -> Brush {
885 self.cursor_brush.clone()
886 }
887
888 pub fn selection_brush(&self) -> Brush {
890 self.selection_brush.clone()
891 }
892
893 pub fn insert_text(&mut self, text: &str) {
895 self.state.edit(|buffer| {
896 buffer.insert(text);
897 });
898 }
899
900 pub fn copy_selection(&self) -> Option<String> {
903 self.state.copy_selection()
904 }
905
906 pub fn cut_selection(&mut self) -> Option<String> {
909 let text = self.copy_selection();
910 if text.is_some() {
911 self.state.edit(|buffer| {
912 buffer.delete(buffer.selection());
913 });
914 }
915 text
916 }
917
918 pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
921 self.state
922 }
923
924 pub fn set_content_offset(&self, offset: f32) {
927 self.refs.content_offset.set(offset);
928 }
929
930 pub fn set_content_y_offset(&self, offset: f32) {
933 self.refs.content_y_offset.set(offset);
934 }
935
936 fn wrap_width(&self, available_width: f32) -> Option<f32> {
943 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
944 .then_some(available_width)
945 }
946
947 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
953 let text = self.state.text();
954 let node_id = self.refs.node_id.get();
955 let annotated = crate::text::AnnotatedString::from(text.as_str());
956 let metrics = match wrap_width {
957 Some(max_width) => crate::text::measure_text_with_options_for_node(
958 node_id,
959 &annotated,
960 &self.style,
961 crate::text::TextLayoutOptions::default(),
962 Some(max_width),
963 ),
964 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
965 };
966 self.measured_line_height.set(metrics.line_height);
967 Size {
968 width: metrics.width,
969 height: metrics.height,
970 }
971 }
972
973 fn update_cached_state(&mut self) -> bool {
975 let value = self.state.value();
976 let text_changed = value.text != self.cached_text;
977 let selection_changed = value.selection != self.cached_selection;
978
979 if text_changed {
980 self.cached_text = value.text;
981 }
982 if selection_changed {
983 self.cached_selection = value.selection;
984 }
985
986 text_changed || selection_changed
987 }
988
989 pub fn position_cursor_at_offset(&self, x_offset: f32) {
992 let text = self.state.text();
993 if text.is_empty() {
994 self.state.edit(|buffer| {
995 buffer.place_cursor_at_start();
996 });
997 return;
998 }
999
1000 let byte_offset = crate::text::get_offset_for_position(
1003 &crate::text::AnnotatedString::from(text.as_str()),
1004 &self.style,
1005 x_offset + self.refs.scroll_offset.get(),
1006 0.0,
1007 );
1008
1009 self.state.edit(|buffer| {
1010 buffer.place_cursor_before_char(byte_offset);
1011 });
1012 }
1013
1014 }
1018
1019impl DelegatableNode for TextFieldModifierNode {
1020 fn node_state(&self) -> &NodeState {
1021 &self.node_state
1022 }
1023}
1024
1025impl ModifierNode for TextFieldModifierNode {
1026 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
1027 self.refs.node_id.set(context.node_id());
1029
1030 context.invalidate(InvalidationKind::Layout);
1031 context.invalidate(InvalidationKind::Draw);
1032 context.invalidate(InvalidationKind::Semantics);
1033 }
1034
1035 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
1036 Some(self)
1037 }
1038
1039 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
1040 Some(self)
1041 }
1042
1043 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
1044 Some(self)
1045 }
1046
1047 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
1048 Some(self)
1049 }
1050
1051 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
1052 Some(self)
1053 }
1054
1055 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
1056 Some(self)
1057 }
1058
1059 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
1060 Some(self)
1061 }
1062
1063 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
1064 Some(self)
1065 }
1066}
1067
1068impl LayoutModifierNode for TextFieldModifierNode {
1069 fn measure(
1070 &self,
1071 _context: &mut dyn ModifierNodeContext,
1072 _measurable: &dyn Measurable,
1073 constraints: Constraints,
1074 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
1075 let wrap_width = self.wrap_width(constraints.max_width);
1079 self.measured_wrap_width.set(wrap_width);
1082 let text_size = self.measure_text_content(wrap_width);
1083
1084 let min_height = if text_size.height < 1.0 {
1086 DEFAULT_LINE_HEIGHT
1087 } else {
1088 text_size.height
1089 };
1090
1091 let width = text_size
1093 .width
1094 .max(constraints.min_width)
1095 .min(constraints.max_width);
1096 let height = min_height
1097 .max(constraints.min_height)
1098 .min(constraints.max_height);
1099
1100 let size = Size { width, height };
1101 self.measured_size.set(size);
1102
1103 let _ = (self.cached_pan_resolver)(size.width);
1106
1107 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
1108 }
1109
1110 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1111 self.measure_text_content(None).width
1112 }
1113
1114 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1115 self.measure_text_content(None).width
1116 }
1117
1118 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1119 self.measure_text_content(self.wrap_width(width))
1120 .height
1121 .max(DEFAULT_LINE_HEIGHT)
1122 }
1123
1124 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1125 self.measure_text_content(self.wrap_width(width))
1126 .height
1127 .max(DEFAULT_LINE_HEIGHT)
1128 }
1129}
1130
1131fn content_viewport(
1135 measured: cranpose_ui_graphics::Size,
1136 size: cranpose_foundation::Size,
1137 padding_left: f32,
1138 padding_top: f32,
1139) -> (f32, f32) {
1140 let width = if measured.width > 0.0 {
1141 measured.width
1142 } else {
1143 (size.width - padding_left).max(0.0)
1144 };
1145 let height = if measured.height > 0.0 {
1146 measured.height
1147 } else {
1148 (size.height - padding_top).max(0.0)
1149 };
1150 (width, height)
1151}
1152
1153impl DrawModifierNode for TextFieldModifierNode {
1154 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
1155 }
1159
1160 fn create_draw_closure(
1161 &self,
1162 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1163 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1164
1165 let is_focused = self.refs.is_focused.clone();
1167 let state = self.state;
1168 let content_offset = self.refs.content_offset.clone();
1169 let content_y_offset = self.refs.content_y_offset.clone();
1170 let cursor_brush = self.cursor_brush.clone();
1171 let style = self.style.clone();
1172 let cached_line_height = self.measured_line_height.clone();
1173 let measured_size = self.measured_size.clone();
1174 let measured_wrap_width = self.measured_wrap_width.clone();
1175 let node_id = self.refs.node_id.clone();
1176 let pan_resolver = self.cached_pan_resolver.clone();
1177 let handle_controller = self.handle_controller.clone();
1178 let node_origin = self.refs.node_origin.clone();
1179 let direct_manipulation = self.refs.direct_manipulation.clone();
1180 let press_track = self.refs.press_track.clone();
1181 let gesture_claimed = self.refs.gesture_claimed.clone();
1182
1183 Some(Rc::new(move |scope| {
1184 let size = scope.size();
1185 if !*is_focused.borrow() {
1187 if let Some(controller) = &handle_controller {
1190 controller.publish(TextFieldHandleMetrics {
1191 focused: false,
1192 direct_manipulation: false,
1193 node_origin: node_origin.get(),
1194 padding_left: 0.0,
1195 padding_top: 0.0,
1196 scroll_offset: 0.0,
1197 line_height: cached_line_height.get(),
1198 glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
1199 wrap_width: measured_wrap_width.get(),
1200 press: None,
1201 });
1202 }
1203 return;
1204 }
1205
1206 let mut primitives = Vec::new();
1207
1208 let text = state.text();
1209 let selection = state.selection();
1210 let padding_left = content_offset.get();
1211 let padding_top = content_y_offset.get();
1212 let line_height = cached_line_height.get();
1215
1216 let (viewport_width, viewport_height) =
1217 content_viewport(measured_size.get(), size, padding_left, padding_top);
1218 let pan = pan_resolver(viewport_width);
1220
1221 if let Some(controller) = &handle_controller {
1224 controller.adopt_gesture_claim(&gesture_claimed);
1225 controller.publish(TextFieldHandleMetrics {
1226 focused: true,
1227 direct_manipulation: direct_manipulation.get(),
1228 node_origin: node_origin.get(),
1229 padding_left,
1230 padding_top,
1231 scroll_offset: pan,
1232 line_height,
1233 glyph_box: crate::text::glyph_line_box(&style, line_height),
1234 wrap_width: measured_wrap_width.get(),
1235 press: press_track.get(),
1236 });
1237 }
1238 let clip_bounds = cranpose_ui_graphics::Rect {
1242 x: padding_left,
1243 y: padding_top,
1244 width: viewport_width,
1245 height: viewport_height,
1246 };
1247
1248 if let Some(comp_range) = state.composition() {
1255 let comp_start = comp_range.min();
1256 let comp_end = comp_range.max();
1257
1258 if comp_start < comp_end && comp_end <= text.len() {
1259 let underline_brush = cranpose_ui_graphics::Brush::solid(
1261 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1262 );
1263 let underline_height: f32 = 2.0;
1264
1265 for line_rect in range_visual_line_rects(
1268 &text,
1269 &style,
1270 node_id.get(),
1271 measured_wrap_width.get(),
1272 padding_left,
1273 padding_top,
1274 pan,
1275 line_height,
1276 comp_start,
1277 comp_end,
1278 ) {
1279 let underline_rect = cranpose_ui_graphics::Rect {
1280 x: line_rect.x,
1281 y: line_rect.y + line_height - underline_height,
1282 width: line_rect.width,
1283 height: underline_height,
1284 };
1285 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1286 primitives.push(DrawPrimitive::Rect {
1287 rect: clipped,
1288 brush: underline_brush.clone(),
1289 stroke: None,
1290 });
1291 }
1292 }
1293 }
1294 }
1295
1296 if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1302 let pos = selection.start.min(text.len());
1303 let (line_index, line_start) = caret_visual_line_for_offset(
1312 &text,
1313 &style,
1314 node_id.get(),
1315 measured_wrap_width.get(),
1316 pos,
1317 crate::text_selection::LineAffinity::Upstream,
1318 );
1319 let cursor_x = crate::text::measure_text(
1320 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1321 &style,
1322 )
1323 .width
1324 + padding_left
1325 - pan;
1326 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1329 let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1330
1331 let cursor_rect = cranpose_ui_graphics::Rect {
1332 x: cursor_x,
1333 y: cursor_y,
1334 width: CURSOR_WIDTH,
1335 height: box_h,
1336 };
1337
1338 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1339 primitives.push(DrawPrimitive::Rect {
1340 rect: clipped,
1341 brush: cursor_brush.clone(),
1342 stroke: None,
1343 });
1344 }
1345 }
1346
1347 scope.push_recorded(primitives);
1348 }))
1349 }
1350
1351 fn create_behind_draw_closure(
1352 &self,
1353 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1354 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1355
1356 let is_focused = self.refs.is_focused.clone();
1357 let state = self.state;
1358 let content_offset = self.refs.content_offset.clone();
1359 let content_y_offset = self.refs.content_y_offset.clone();
1360 let selection_brush = self.selection_brush.clone();
1361 let style = self.style.clone();
1362 let cached_line_height = self.measured_line_height.clone();
1363 let measured_size = self.measured_size.clone();
1364 let measured_wrap_width = self.measured_wrap_width.clone();
1365 let node_id = self.refs.node_id.clone();
1366 let pan_resolver = self.cached_pan_resolver.clone();
1367
1368 Some(Rc::new(move |scope| {
1369 let size = scope.size();
1370 if !*is_focused.borrow() {
1371 return;
1372 }
1373 let selection = state.selection();
1374 if selection.collapsed() {
1375 return;
1376 }
1377 let text = state.text();
1378 let padding_left = content_offset.get();
1379 let padding_top = content_y_offset.get();
1380 let line_height = cached_line_height.get();
1381 let (viewport_width, viewport_height) =
1382 content_viewport(measured_size.get(), size, padding_left, padding_top);
1383 let pan = pan_resolver(viewport_width);
1384 let clip_bounds = cranpose_ui_graphics::Rect {
1385 x: padding_left,
1386 y: padding_top,
1387 width: viewport_width,
1388 height: viewport_height,
1389 };
1390
1391 let mut primitives = Vec::new();
1395 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1399 for sel_rect in range_visual_line_rects(
1400 &text,
1401 &style,
1402 node_id.get(),
1403 measured_wrap_width.get(),
1404 padding_left,
1405 padding_top,
1406 pan,
1407 line_height,
1408 selection.min(),
1409 selection.max(),
1410 ) {
1411 let sel_rect = cranpose_ui_graphics::Rect {
1412 y: sel_rect.y + box_off,
1413 height: box_h,
1414 ..sel_rect
1415 };
1416 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1417 primitives.push(DrawPrimitive::Rect {
1418 rect: clipped,
1419 brush: selection_brush.clone(),
1420 stroke: None,
1421 });
1422 }
1423 }
1424 scope.push_recorded(primitives);
1425 }))
1426 }
1427}
1428
1429impl SemanticsNode for TextFieldModifierNode {
1430 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1431 let text = self.state.text();
1432 config.content_description = Some(text);
1433 config.is_editable_text = true;
1434 config.text_selection = Some(self.state.selection());
1435 }
1436}
1437
1438impl PointerInputNode for TextFieldModifierNode {
1439 fn on_pointer_event(
1440 &mut self,
1441 _context: &mut dyn ModifierNodeContext,
1442 _event: &PointerEvent,
1443 ) -> bool {
1444 false
1455 }
1456
1457 fn hit_test(&self, x: f32, y: f32) -> bool {
1458 let size = self.measured_size.get();
1460 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1461 }
1462
1463 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1464 Some(self.cached_handler.clone())
1466 }
1467}
1468
1469#[derive(Clone)]
1480pub struct TextFieldElement {
1481 state: TextFieldState,
1483 style: TextStyle,
1485 cursor_color: Color,
1487 line_limits: TextFieldLineLimits,
1489 handle_controller: Option<TextFieldHandleController>,
1492}
1493
1494impl TextFieldElement {
1495 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1497 Self {
1498 state,
1499 style,
1500 cursor_color: DEFAULT_CURSOR_COLOR,
1501 line_limits: TextFieldLineLimits::default(),
1502 handle_controller: None,
1503 }
1504 }
1505
1506 pub fn with_cursor_color(mut self, color: Color) -> Self {
1508 self.cursor_color = color;
1509 self
1510 }
1511
1512 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1514 self.line_limits = line_limits;
1515 self
1516 }
1517
1518 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1520 self.handle_controller = Some(controller);
1521 self
1522 }
1523}
1524
1525impl std::fmt::Debug for TextFieldElement {
1526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1527 f.debug_struct("TextFieldElement")
1528 .field("text", &self.state.text())
1529 .field("style", &self.style)
1530 .field("cursor_color", &self.cursor_color)
1531 .finish()
1532 }
1533}
1534
1535impl Hash for TextFieldElement {
1536 fn hash<H: Hasher>(&self, state: &mut H) {
1537 self.state.id().hash(state);
1540 self.cursor_color.0.to_bits().hash(state);
1542 self.cursor_color.1.to_bits().hash(state);
1543 self.cursor_color.2.to_bits().hash(state);
1544 self.cursor_color.3.to_bits().hash(state);
1545 self.style.render_hash().hash(state);
1546 self.line_limits.hash(state);
1547 }
1548}
1549
1550impl PartialEq for TextFieldElement {
1551 fn eq(&self, other: &Self) -> bool {
1552 self.state == other.state
1556 && self.style == other.style
1557 && self.cursor_color == other.cursor_color
1558 && self.line_limits == other.line_limits
1559 }
1560}
1561
1562impl Eq for TextFieldElement {}
1563
1564impl ModifierNodeElement for TextFieldElement {
1565 type Node = TextFieldModifierNode;
1566
1567 fn create(&self) -> Self::Node {
1568 let mut node = TextFieldModifierNode::new(self.state, self.style.clone())
1569 .with_cursor_color(self.cursor_color)
1570 .with_line_limits(self.line_limits);
1571 if let Some(controller) = self.handle_controller.clone() {
1572 node = node.with_handle_controller(controller);
1573 }
1574 node
1575 }
1576
1577 fn update(&self, node: &mut Self::Node) {
1578 node.state = self.state;
1580 node.style = self.style.clone();
1581 node.cursor_brush = Brush::solid(self.cursor_color);
1582 node.line_limits = self.line_limits;
1583 node.handle_controller = self.handle_controller.clone();
1584
1585 node.cached_handler = TextFieldModifierNode::create_handler(
1587 node.state,
1588 node.refs.clone(),
1589 node.line_limits,
1590 self.style.clone(),
1591 );
1592
1593 node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1595 node.state,
1596 node.refs.clone(),
1597 node.line_limits,
1598 self.style.clone(),
1599 );
1600
1601 if node.update_cached_state() {
1603 }
1606 }
1607
1608 fn capabilities(&self) -> NodeCapabilities {
1609 NodeCapabilities::LAYOUT
1610 | NodeCapabilities::DRAW
1611 | NodeCapabilities::SEMANTICS
1612 | NodeCapabilities::POINTER_INPUT
1613 }
1614
1615 fn always_update(&self) -> bool {
1616 true
1618 }
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623 use super::*;
1624 use crate::text::TextStyle;
1625 use cranpose_core::{DefaultScheduler, Runtime};
1626 use std::sync::Arc;
1627
1628 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1630 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1631 f()
1632 }
1633
1634 #[test]
1635 fn text_field_node_creation() {
1636 let _app_context = crate::render_state::app_context_test_scope();
1637 with_test_runtime(|| {
1638 let state = TextFieldState::new("Hello");
1639 let node = TextFieldModifierNode::new(state, TextStyle::default());
1640 assert_eq!(node.text(), "Hello");
1641 assert!(!node.is_focused());
1642 });
1643 }
1644
1645 #[test]
1650 fn selection_rects_follow_wrapped_visual_lines() {
1651 let _app_context = crate::render_state::app_context_test_scope();
1652 let text = "aaaaa\nbb";
1655 let style = TextStyle::default();
1656 let line_height = 10.0_f32;
1657
1658 let rects = range_visual_line_rects(
1661 text,
1662 &style,
1663 None,
1664 Some(30.0),
1665 0.0,
1666 0.0,
1667 0.0,
1668 line_height,
1669 6,
1670 8,
1671 );
1672 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1673 assert_eq!(
1674 rects[0].y,
1675 2.0 * line_height,
1676 "highlight must land on visual line 2, not logical line 1"
1677 );
1678 assert!(rects[0].width > 0.0);
1679
1680 let spanning = range_visual_line_rects(
1683 text,
1684 &style,
1685 None,
1686 Some(30.0),
1687 0.0,
1688 0.0,
1689 0.0,
1690 line_height,
1691 0,
1692 5,
1693 );
1694 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1695 assert_eq!(spanning[0].y, 0.0);
1696 assert_eq!(spanning[1].y, line_height);
1697 }
1698
1699 #[test]
1704 fn tap_resolves_offset_on_wrapped_visual_line() {
1705 let _app_context = crate::render_state::app_context_test_scope();
1706 let text = "aaaaa\nbb";
1709 let style = TextStyle::default();
1710 let line_height = 10.0_f32;
1711
1712 let off = crate::text::offset_for_position_wrapped(
1715 text,
1716 &style,
1717 None,
1718 Some(30.0),
1719 line_height,
1720 8.0,
1721 22.0,
1722 );
1723 assert!(
1724 (6..=8).contains(&off),
1725 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1726 );
1727
1728 let off1 = crate::text::offset_for_position_wrapped(
1731 text,
1732 &style,
1733 None,
1734 Some(30.0),
1735 line_height,
1736 4.0,
1737 12.0,
1738 );
1739 assert!(
1740 (3..=5).contains(&off1),
1741 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1742 );
1743
1744 let off2 = crate::text::offset_for_position_wrapped(
1746 "hello",
1747 &style,
1748 None,
1749 None,
1750 line_height,
1751 0.0,
1752 0.0,
1753 );
1754 assert_eq!(off2, 0);
1755 }
1756
1757 #[test]
1758 fn text_field_node_focus() {
1759 let _app_context = crate::render_state::app_context_test_scope();
1760 with_test_runtime(|| {
1761 let state = TextFieldState::new("Test");
1762 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1763 assert!(!node.is_focused());
1764
1765 node.set_focused(true);
1766 assert!(node.is_focused());
1767
1768 node.set_focused(false);
1769 assert!(!node.is_focused());
1770 });
1771 }
1772
1773 #[test]
1774 fn text_field_element_creates_node() {
1775 let _app_context = crate::render_state::app_context_test_scope();
1776 with_test_runtime(|| {
1777 let state = TextFieldState::new("Hello World");
1778 let element = TextFieldElement::new(state, TextStyle::default());
1779
1780 let node = element.create();
1781 assert_eq!(node.text(), "Hello World");
1782 });
1783 }
1784
1785 #[test]
1790 fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1791 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1792 use cranpose_ui_graphics::Point;
1793
1794 let _app_context = crate::render_state::app_context_test_scope();
1795 with_test_runtime(|| {
1796 let state = TextFieldState::new("hello world");
1797 let controller = TextFieldHandleController::new();
1798 let mut node = TextFieldModifierNode::new(state, TextStyle::default())
1799 .with_handle_controller(controller.clone());
1800 node.measured_size.set(Size {
1802 width: 120.0,
1803 height: 20.0,
1804 });
1805
1806 let handler = node
1807 .pointer_input_handler()
1808 .expect("field exposes a pointer handler");
1809 let draw = node
1810 .create_draw_closure()
1811 .expect("field exposes a draw closure");
1812 let at = Point { x: 12.0, y: 8.0 };
1813 let size = Size {
1814 width: 120.0,
1815 height: 20.0,
1816 };
1817 let run_draw = || {
1820 let mut scope = crate::draw::command_draw_scope(size);
1821 draw(&mut scope);
1822 };
1823
1824 node.set_focused(true);
1825 run_draw();
1826 let keyboard_metrics = controller
1827 .metrics()
1828 .expect("focused field publishes handle metrics");
1829 assert!(!keyboard_metrics.direct_manipulation);
1830
1831 handler(
1832 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1833 );
1834 run_draw();
1835 let metrics = controller
1836 .metrics()
1837 .expect("focused field publishes handle metrics");
1838 assert!(metrics.focused, "a tap focuses the field");
1839 assert!(
1840 metrics.direct_manipulation,
1841 "a touch tap must expose direct-manipulation handles"
1842 );
1843 assert!(metrics.press.is_some(), "touch must publish the live press");
1844
1845 handler(
1846 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1847 );
1848 run_draw();
1849 let metrics = controller
1850 .metrics()
1851 .expect("focused field publishes handle metrics");
1852 assert!(
1853 metrics.direct_manipulation,
1854 "a mouse tap must expose the same direct-manipulation handles"
1855 );
1856 assert!(metrics.press.is_some(), "mouse must publish the live press");
1857
1858 handler(
1859 PointerEvent::new(PointerEventKind::Down, at, at)
1860 .with_source(PointerSource::Stylus),
1861 );
1862 run_draw();
1863 let metrics = controller
1864 .metrics()
1865 .expect("focused field publishes handle metrics");
1866 assert!(
1867 metrics.direct_manipulation,
1868 "a stylus contact must expose the same direct-manipulation handles"
1869 );
1870 assert!(
1871 metrics.press.is_some(),
1872 "stylus must publish the live press"
1873 );
1874
1875 crate::text_field_focus::clear_focus();
1876 });
1877 }
1878
1879 #[test]
1887 fn double_tap_selects_the_word_under_the_finger() {
1888 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1889 use cranpose_ui_graphics::Point;
1890
1891 let _app_context = crate::render_state::app_context_test_scope();
1892 with_test_runtime(|| {
1893 let state = TextFieldState::new("hello world");
1894 let node = TextFieldModifierNode::new(state, TextStyle::default());
1895 node.measured_size.set(Size {
1896 width: 200.0,
1897 height: 20.0,
1898 });
1899 let handler = node
1900 .pointer_input_handler()
1901 .expect("field exposes a pointer handler");
1902
1903 let at = Point { x: 2.0, y: 8.0 };
1906 handler(
1907 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1908 );
1909 handler(
1910 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1911 );
1912
1913 let selection = state.selection();
1914 assert!(
1915 !selection.collapsed(),
1916 "a double tap must produce a (word) selection, got {selection:?}"
1917 );
1918 let selected = &state.text()[selection.min()..selection.max()];
1919 assert_eq!(
1920 selected, "hello",
1921 "double tap should select the whole word under the finger"
1922 );
1923
1924 crate::text_field_focus::clear_focus();
1925 });
1926 }
1927
1928 #[test]
1932 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1933 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1934 use cranpose_ui_graphics::Point;
1935
1936 let _app_context = crate::render_state::app_context_test_scope();
1937 with_test_runtime(|| {
1938 let text = "alpha beta\ngamma delta\n\nsecond para";
1941 let state = TextFieldState::new(text);
1942 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
1943 TextFieldLineLimits::MultiLine {
1944 min_lines: 1,
1945 max_lines: usize::MAX,
1946 },
1947 );
1948 node.measured_size.set(Size {
1949 width: 400.0,
1950 height: 80.0,
1951 });
1952 let handler = node
1953 .pointer_input_handler()
1954 .expect("field exposes a pointer handler");
1955
1956 let at = Point { x: 2.0, y: 4.0 };
1958 let tap = || {
1959 handler(
1960 PointerEvent::new(PointerEventKind::Down, at, at)
1961 .with_source(PointerSource::Touch),
1962 );
1963 };
1964 let selected = |state: &TextFieldState| {
1965 let s = state.selection();
1966 state.text()[s.min()..s.max()].to_string()
1967 };
1968
1969 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1971 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1973 tap(); assert_eq!(
1975 selected(&state),
1976 "alpha beta",
1977 "triple tap selects the line"
1978 );
1979 tap(); assert_eq!(
1981 selected(&state),
1982 "alpha beta\ngamma delta",
1983 "fourth tap grows to the paragraph"
1984 );
1985 tap(); assert_eq!(
1987 selected(&state),
1988 "alpha",
1989 "fifth tap cycles back to the word"
1990 );
1991
1992 crate::text_field_focus::clear_focus();
1993 });
1994 }
1995
1996 #[test]
2000 fn single_tap_inside_selection_selects_the_word() {
2001 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2002 use cranpose_ui_graphics::Point;
2003
2004 let _app_context = crate::render_state::app_context_test_scope();
2005 with_test_runtime(|| {
2006 let state = TextFieldState::new("hello world");
2007 let node = TextFieldModifierNode::new(state, TextStyle::default());
2008 node.measured_size.set(Size {
2009 width: 200.0,
2010 height: 20.0,
2011 });
2012 let handler = node
2013 .pointer_input_handler()
2014 .expect("field exposes a pointer handler");
2015
2016 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
2018 assert!(!state.selection().collapsed());
2019
2020 let at = Point { x: 2.0, y: 8.0 };
2023 handler(
2024 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
2025 );
2026
2027 let selection = state.selection();
2028 assert!(
2029 !selection.collapsed(),
2030 "a tap inside a selection must not collapse it, got {selection:?}"
2031 );
2032 assert_eq!(
2033 &state.text()[selection.min()..selection.max()],
2034 "hello",
2035 "a tap inside a selection re-selects the word under the finger"
2036 );
2037
2038 crate::text_field_focus::clear_focus();
2039 });
2040 }
2041
2042 #[test]
2050 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
2051 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
2052 use cranpose_ui_graphics::Point;
2053
2054 let _app_context = crate::render_state::app_context_test_scope();
2055 with_test_runtime(|| {
2056 let text = "alpha beta\ngamma delta\n\nsecond para";
2057 let state = TextFieldState::new(text);
2058 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
2059 TextFieldLineLimits::MultiLine {
2060 min_lines: 1,
2061 max_lines: usize::MAX,
2062 },
2063 );
2064 node.measured_size.set(Size {
2065 width: 400.0,
2066 height: 80.0,
2067 });
2068 let handler = node
2069 .pointer_input_handler()
2070 .expect("field exposes a pointer handler");
2071
2072 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
2074
2075 let at = Point { x: 2.0, y: 4.0 };
2076 let selected = |state: &TextFieldState| {
2077 let s = state.selection();
2078 state.text()[s.min()..s.max()].to_string()
2079 };
2080 let slow_tap = || {
2083 node.refs.last_click_time.set(None);
2084 handler(
2085 PointerEvent::new(PointerEventKind::Down, at, at)
2086 .with_source(PointerSource::Touch),
2087 );
2088 };
2089
2090 slow_tap(); assert_eq!(
2092 selected(&state),
2093 "alpha",
2094 "tap inside selection grabs the word"
2095 );
2096 slow_tap(); assert_eq!(
2098 selected(&state),
2099 "alpha beta",
2100 "same-spot tap grows to the line even after the timeout"
2101 );
2102 slow_tap(); assert_eq!(
2104 selected(&state),
2105 "alpha beta\ngamma delta",
2106 "same-spot tap grows to the paragraph"
2107 );
2108 slow_tap(); assert_eq!(
2110 selected(&state),
2111 "alpha",
2112 "same-spot tap cycles back to the word"
2113 );
2114
2115 crate::text_field_focus::clear_focus();
2116 });
2117 }
2118
2119 #[test]
2120 fn text_field_element_equality() {
2121 let _app_context = crate::render_state::app_context_test_scope();
2122 with_test_runtime(|| {
2123 let state1 = TextFieldState::new("Hello");
2124 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1, TextStyle::default());
2127 let elem2 = TextFieldElement::new(state1, TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
2133 assert_ne!(elem1, elem3, "Different states should not be equal");
2134 });
2135 }
2136
2137 #[test]
2138 fn text_field_element_update_refreshes_existing_node_style() {
2139 let _app_context = crate::render_state::app_context_test_scope();
2140 with_test_runtime(|| {
2141 let state = TextFieldState::new("themed text");
2142 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
2143 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
2144 ..crate::text::SpanStyle::default()
2145 });
2146 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
2147 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
2148 ..crate::text::SpanStyle::default()
2149 });
2150 let initial = TextFieldElement::new(state, dark_style);
2151 let updated = TextFieldElement::new(state, light_style.clone());
2152 let mut node = initial.create();
2153
2154 updated.update(&mut node);
2155
2156 assert_eq!(node.text(), "themed text");
2157 assert_eq!(node.style(), &light_style);
2158 });
2159 }
2160
2161 #[test]
2166 fn multiline_field_measures_wrapped_height() {
2167 let _app_context = crate::render_state::app_context_test_scope();
2168 with_test_runtime(|| {
2169 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
2171 let node = TextFieldModifierNode::new(state, TextStyle::default());
2172 assert!(
2173 !node.line_limits().is_single_line(),
2174 "default fields are multi-line"
2175 );
2176
2177 let natural = node.measure_text_content(None);
2178 let wrapped = node.measure_text_content(node.wrap_width(20.0));
2179
2180 assert!(
2181 wrapped.height > natural.height,
2182 "wrapped multi-line height {} must exceed the single-line height {}",
2183 wrapped.height,
2184 natural.height
2185 );
2186 });
2187 }
2188
2189 #[test]
2192 fn single_line_field_never_wraps() {
2193 let _app_context = crate::render_state::app_context_test_scope();
2194 with_test_runtime(|| {
2195 let state = TextFieldState::new("abcd ".repeat(40));
2196 let node = TextFieldModifierNode::new(state, TextStyle::default())
2197 .with_line_limits(TextFieldLineLimits::SingleLine);
2198 assert_eq!(
2199 node.wrap_width(20.0),
2200 None,
2201 "single-line fields must not wrap"
2202 );
2203 });
2204 }
2205
2206 #[test]
2212 fn test_cursor_x_position_calculation() {
2213 let _app_context = crate::render_state::app_context_test_scope();
2214 with_test_runtime(|| {
2215 let style = crate::text::TextStyle::default();
2217
2218 let empty_width =
2220 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2221 assert!(
2222 empty_width.abs() < 0.1,
2223 "Empty text should have 0 width, got {}",
2224 empty_width
2225 );
2226
2227 let hi_width =
2229 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2230 assert!(
2231 hi_width > 0.0,
2232 "Text 'Hi' should have positive width: {}",
2233 hi_width
2234 );
2235
2236 let h_width =
2238 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2239 assert!(h_width > 0.0, "Text 'H' should have positive width");
2240 assert!(
2241 h_width < hi_width,
2242 "'H' width {} should be less than 'Hi' width {}",
2243 h_width,
2244 hi_width
2245 );
2246
2247 let state = TextFieldState::new("Hi");
2249 assert_eq!(
2250 state.selection().start,
2251 2,
2252 "Cursor should be at position 2 (end of 'Hi')"
2253 );
2254
2255 let text = state.text();
2257 let cursor_pos = state.selection().start;
2258 let text_before_cursor = &text[..cursor_pos.min(text.len())];
2259 assert_eq!(text_before_cursor, "Hi");
2260
2261 let cursor_x = crate::text::measure_text(
2263 &crate::text::AnnotatedString::from(text_before_cursor),
2264 &style,
2265 )
2266 .width;
2267 assert!(
2268 (cursor_x - hi_width).abs() < 0.1,
2269 "Cursor x {} should equal 'Hi' width {}",
2270 cursor_x,
2271 hi_width
2272 );
2273 });
2274 }
2275
2276 #[test]
2278 fn test_focused_node_creates_cursor() {
2279 let _app_context = crate::render_state::app_context_test_scope();
2280 with_test_runtime(|| {
2281 let state = TextFieldState::new("Test");
2282 let element = TextFieldElement::new(state, TextStyle::default());
2283 let node = element.create();
2284
2285 assert!(!node.is_focused());
2287
2288 *node.refs.is_focused.borrow_mut() = true;
2290 assert!(node.is_focused());
2291
2292 assert_eq!(node.text(), "Test");
2294
2295 assert_eq!(node.selection().start, 4);
2297 });
2298 }
2299}