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