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 =
480 Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
481 let cached_pan_resolver =
482 Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
483
484 Self {
485 state,
486 refs,
487 style,
488 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
489 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
490 line_limits,
491 cached_text: value.text,
492 cached_selection: value.selection,
493 node_state: NodeState::new(),
494 measured_size: Rc::new(Cell::new(Size {
495 width: 0.0,
496 height: 0.0,
497 })),
498 measured_line_height: refs_line_height,
502 measured_wrap_width: refs_wrap_width,
503 cached_handler,
504 cached_pan_resolver,
505 handle_controller: None,
506 }
507 }
508
509 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
511 self.line_limits = line_limits;
512 self.cached_pan_resolver = Self::create_pan_resolver(
513 self.state.clone(),
514 self.refs.clone(),
515 line_limits,
516 self.style.clone(),
517 );
518 self
519 }
520
521 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
523 self.handle_controller = Some(controller);
524 self
525 }
526
527 fn create_pan_resolver(
535 state: TextFieldState,
536 refs: TextFieldRefs,
537 line_limits: TextFieldLineLimits,
538 style: TextStyle,
539 ) -> TextPanResolver {
540 Rc::new(move |viewport_width: f32| {
541 if !line_limits.is_single_line() {
542 refs.scroll_offset.set(0.0);
544 return 0.0;
545 }
546 let text = state.text();
547 let pos = state.selection().start.min(text.len());
548 let text_width = crate::text::measure_text(
549 &crate::text::AnnotatedString::from(text.as_str()),
550 &style,
551 )
552 .width;
553 let cursor_x = crate::text::measure_text(
554 &crate::text::AnnotatedString::from(&text[..pos]),
555 &style,
556 )
557 .width;
558 let offset = compute_horizontal_scroll_offset(
559 refs.scroll_offset.get(),
560 cursor_x,
561 text_width,
562 viewport_width,
563 );
564 refs.scroll_offset.set(offset);
565 offset
566 })
567 }
568
569 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
574 self.line_limits
575 .is_single_line()
576 .then(|| self.cached_pan_resolver.clone())
577 }
578
579 pub fn scroll_offset(&self) -> f32 {
581 self.refs.scroll_offset.get()
582 }
583
584 pub fn line_limits(&self) -> TextFieldLineLimits {
586 self.line_limits
587 }
588
589 fn create_handler(
591 state: TextFieldState,
592 refs: TextFieldRefs,
593 line_limits: TextFieldLineLimits,
594 style: TextStyle, ) -> Rc<dyn Fn(PointerEvent)> {
596 use crate::text_selection::{
599 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
600 resolve_selection_tap_count, tap_selection_granularity, SelectionGranularity,
601 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS,
602 };
603 use crate::word_boundaries::find_word_boundaries;
604
605 Rc::new(move |event: PointerEvent| {
606 refs.node_origin.set(Point {
613 x: event.global_position.x - event.position.x,
614 y: event.global_position.y - event.position.y,
615 });
616
617 let click_x =
621 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
622 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
623
624 match event.kind {
625 PointerEventKind::Down => {
626 refs.direct_manipulation.set(true);
630 refs.press_track.set(Some(PointerPressTrack {
631 start: event.global_position,
632 position: event.global_position,
633 }));
634 refs.gesture_claimed.set(false);
635
636 let handler = TextFieldHandler::new(
641 state.clone(),
642 refs.node_id.get(),
643 line_limits,
644 crate::text_field_handler::CaretGeometryRefs {
645 node_origin: refs.node_origin.clone(),
646 content_offset: refs.content_offset.clone(),
647 content_y_offset: refs.content_y_offset.clone(),
648 scroll_offset: refs.scroll_offset.clone(),
649 style: style.clone(),
650 },
651 );
652 crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
653
654 let now = web_time::Instant::now();
655 let text = state.text();
656 let pos = crate::text::offset_for_position_wrapped(
657 &text,
658 &style,
659 refs.node_id.get(),
660 refs.wrap_width.get(),
661 refs.line_height.get(),
662 click_x,
663 click_y,
664 );
665
666 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
671 let count = refs.click_count.get();
672 (count > 0).then_some((count, px, py))
673 });
674 let elapsed_ms = refs
675 .last_click_time
676 .get()
677 .map(|last| now.duration_since(last).as_millis())
678 .unwrap_or(u128::MAX);
679 let tap_count = classify_tap_count(
680 previous,
681 elapsed_ms,
682 event.position.x,
683 event.position.y,
684 MULTI_TAP_TIMEOUT_MS,
685 MULTI_TAP_SLOP_PX,
686 );
687
688 let selection = state.selection();
696 let tap_in_selection =
697 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
698 let repeat_in_place = refs
701 .last_click_pos
702 .get()
703 .map(|(px, py)| {
704 let dx = event.position.x - px;
705 let dy = event.position.y - py;
706 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
707 })
708 .unwrap_or(false);
709 let effective_count = resolve_selection_tap_count(
710 tap_count,
711 refs.click_count.get(),
712 tap_in_selection,
713 repeat_in_place,
714 );
715
716 match tap_selection_granularity(effective_count) {
717 SelectionGranularity::Paragraph => {
718 let (start, end) = find_paragraph_boundaries(&text, pos);
720 state.edit(|buffer| {
721 buffer.select(TextRange::new(start, end));
722 });
723 refs.drag_anchor.set(Some(start));
724 }
725 SelectionGranularity::Line => {
726 let (line_start, line_end) = find_line_boundaries(&text, pos);
728 state.edit(|buffer| {
729 buffer.select(TextRange::new(line_start, line_end));
730 });
731 refs.drag_anchor.set(Some(line_start));
732 }
733 SelectionGranularity::Word => {
734 let (word_start, word_end) = find_word_boundaries(&text, pos);
737 state.edit(|buffer| {
738 buffer.select(TextRange::new(word_start, word_end));
739 });
740 refs.drag_anchor.set(Some(word_start));
741 }
742 SelectionGranularity::Caret => {
743 refs.drag_anchor.set(Some(pos));
745 state.edit(|buffer| {
746 buffer.place_cursor_before_char(pos);
747 });
748 }
749 }
750
751 refs.click_count.set(effective_count);
752 refs.last_click_time.set(Some(now));
753 refs.last_click_pos
754 .set(Some((event.position.x, event.position.y)));
755 event.consume();
756 }
757 PointerEventKind::Move => {
758 if let Some(mut track) = refs.press_track.get() {
760 track.position = event.global_position;
761 refs.press_track.set(Some(track));
762 crate::request_render_invalidation();
763 }
764 if refs.gesture_claimed.get() {
767 event.consume();
768 return;
769 }
770 if let Some(anchor) = refs.drag_anchor.get() {
772 if *refs.is_focused.borrow() {
773 let text = state.text();
774 let current_pos = crate::text::offset_for_position_wrapped(
775 &text,
776 &style,
777 refs.node_id.get(),
778 refs.wrap_width.get(),
779 refs.line_height.get(),
780 click_x,
781 click_y,
782 );
783
784 state.set_selection(TextRange::new(anchor, current_pos));
786
787 crate::request_render_invalidation();
789
790 event.consume();
791 }
792 }
793 }
794 PointerEventKind::Up => {
795 refs.drag_anchor.set(None);
797 refs.press_track.set(None);
798 refs.gesture_claimed.set(false);
799 }
800 PointerEventKind::Cancel => {
801 refs.press_track.set(None);
802 refs.gesture_claimed.set(false);
803 }
804 _ => {}
805 }
806 })
807 }
808
809 pub fn with_cursor_color(mut self, color: Color) -> Self {
814 self.cursor_brush = Brush::solid(color);
815 self.selection_brush = Brush::solid(
816 color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
817 );
818 self
819 }
820
821 pub fn set_focused(&mut self, focused: bool) {
823 let current = *self.refs.is_focused.borrow();
824 if current != focused {
825 *self.refs.is_focused.borrow_mut() = focused;
826 if !focused {
827 self.refs.direct_manipulation.set(false);
828 self.refs.press_track.set(None);
829 self.refs.gesture_claimed.set(false);
830 }
831 }
832 }
833
834 pub fn is_focused(&self) -> bool {
836 *self.refs.is_focused.borrow()
837 }
838
839 pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
841 self.refs.is_focused.clone()
842 }
843
844 pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
846 self.refs.content_offset.clone()
847 }
848
849 pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
851 self.refs.content_y_offset.clone()
852 }
853
854 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
867 self.refs.node_origin.clone()
868 }
869
870 pub fn text(&self) -> String {
872 self.state.text()
873 }
874
875 pub fn style(&self) -> &TextStyle {
876 &self.style
877 }
878
879 pub fn selection(&self) -> TextRange {
881 self.state.selection()
882 }
883
884 pub fn cursor_brush(&self) -> Brush {
886 self.cursor_brush.clone()
887 }
888
889 pub fn selection_brush(&self) -> Brush {
891 self.selection_brush.clone()
892 }
893
894 pub fn insert_text(&mut self, text: &str) {
896 self.state.edit(|buffer| {
897 buffer.insert(text);
898 });
899 }
900
901 pub fn copy_selection(&self) -> Option<String> {
904 self.state.copy_selection()
905 }
906
907 pub fn cut_selection(&mut self) -> Option<String> {
910 let text = self.copy_selection();
911 if text.is_some() {
912 self.state.edit(|buffer| {
913 buffer.delete(buffer.selection());
914 });
915 }
916 text
917 }
918
919 pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
922 self.state.clone()
923 }
924
925 pub fn set_content_offset(&self, offset: f32) {
928 self.refs.content_offset.set(offset);
929 }
930
931 pub fn set_content_y_offset(&self, offset: f32) {
934 self.refs.content_y_offset.set(offset);
935 }
936
937 fn wrap_width(&self, available_width: f32) -> Option<f32> {
944 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
945 .then_some(available_width)
946 }
947
948 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
954 let text = self.state.text();
955 let node_id = self.refs.node_id.get();
956 let annotated = crate::text::AnnotatedString::from(text.as_str());
957 let metrics = match wrap_width {
958 Some(max_width) => crate::text::measure_text_with_options_for_node(
959 node_id,
960 &annotated,
961 &self.style,
962 crate::text::TextLayoutOptions::default(),
963 Some(max_width),
964 ),
965 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
966 };
967 self.measured_line_height.set(metrics.line_height);
968 Size {
969 width: metrics.width,
970 height: metrics.height,
971 }
972 }
973
974 fn update_cached_state(&mut self) -> bool {
976 let value = self.state.value();
977 let text_changed = value.text != self.cached_text;
978 let selection_changed = value.selection != self.cached_selection;
979
980 if text_changed {
981 self.cached_text = value.text;
982 }
983 if selection_changed {
984 self.cached_selection = value.selection;
985 }
986
987 text_changed || selection_changed
988 }
989
990 pub fn position_cursor_at_offset(&self, x_offset: f32) {
993 let text = self.state.text();
994 if text.is_empty() {
995 self.state.edit(|buffer| {
996 buffer.place_cursor_at_start();
997 });
998 return;
999 }
1000
1001 let byte_offset = crate::text::get_offset_for_position(
1004 &crate::text::AnnotatedString::from(text.as_str()),
1005 &self.style,
1006 x_offset + self.refs.scroll_offset.get(),
1007 0.0,
1008 );
1009
1010 self.state.edit(|buffer| {
1011 buffer.place_cursor_before_char(byte_offset);
1012 });
1013 }
1014
1015 }
1019
1020impl DelegatableNode for TextFieldModifierNode {
1021 fn node_state(&self) -> &NodeState {
1022 &self.node_state
1023 }
1024}
1025
1026impl ModifierNode for TextFieldModifierNode {
1027 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
1028 self.refs.node_id.set(context.node_id());
1030
1031 context.invalidate(InvalidationKind::Layout);
1032 context.invalidate(InvalidationKind::Draw);
1033 context.invalidate(InvalidationKind::Semantics);
1034 }
1035
1036 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
1037 Some(self)
1038 }
1039
1040 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
1041 Some(self)
1042 }
1043
1044 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
1045 Some(self)
1046 }
1047
1048 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
1049 Some(self)
1050 }
1051
1052 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
1053 Some(self)
1054 }
1055
1056 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
1057 Some(self)
1058 }
1059
1060 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
1061 Some(self)
1062 }
1063
1064 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
1065 Some(self)
1066 }
1067}
1068
1069impl LayoutModifierNode for TextFieldModifierNode {
1070 fn measure(
1071 &self,
1072 _context: &mut dyn ModifierNodeContext,
1073 _measurable: &dyn Measurable,
1074 constraints: Constraints,
1075 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
1076 let wrap_width = self.wrap_width(constraints.max_width);
1080 self.measured_wrap_width.set(wrap_width);
1083 let text_size = self.measure_text_content(wrap_width);
1084
1085 let min_height = if text_size.height < 1.0 {
1087 DEFAULT_LINE_HEIGHT
1088 } else {
1089 text_size.height
1090 };
1091
1092 let width = text_size
1094 .width
1095 .max(constraints.min_width)
1096 .min(constraints.max_width);
1097 let height = min_height
1098 .max(constraints.min_height)
1099 .min(constraints.max_height);
1100
1101 let size = Size { width, height };
1102 self.measured_size.set(size);
1103
1104 let _ = (self.cached_pan_resolver)(size.width);
1107
1108 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
1109 }
1110
1111 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1112 self.measure_text_content(None).width
1113 }
1114
1115 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
1116 self.measure_text_content(None).width
1117 }
1118
1119 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1120 self.measure_text_content(self.wrap_width(width))
1121 .height
1122 .max(DEFAULT_LINE_HEIGHT)
1123 }
1124
1125 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
1126 self.measure_text_content(self.wrap_width(width))
1127 .height
1128 .max(DEFAULT_LINE_HEIGHT)
1129 }
1130}
1131
1132fn content_viewport(
1136 measured: cranpose_ui_graphics::Size,
1137 size: cranpose_foundation::Size,
1138 padding_left: f32,
1139 padding_top: f32,
1140) -> (f32, f32) {
1141 let width = if measured.width > 0.0 {
1142 measured.width
1143 } else {
1144 (size.width - padding_left).max(0.0)
1145 };
1146 let height = if measured.height > 0.0 {
1147 measured.height
1148 } else {
1149 (size.height - padding_top).max(0.0)
1150 };
1151 (width, height)
1152}
1153
1154impl DrawModifierNode for TextFieldModifierNode {
1155 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
1156 }
1160
1161 fn create_draw_closure(
1162 &self,
1163 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1164 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1165
1166 let is_focused = self.refs.is_focused.clone();
1168 let state = self.state.clone();
1169 let content_offset = self.refs.content_offset.clone();
1170 let content_y_offset = self.refs.content_y_offset.clone();
1171 let cursor_brush = self.cursor_brush.clone();
1172 let style = self.style.clone();
1173 let cached_line_height = self.measured_line_height.clone();
1174 let measured_size = self.measured_size.clone();
1175 let measured_wrap_width = self.measured_wrap_width.clone();
1176 let node_id = self.refs.node_id.clone();
1177 let pan_resolver = self.cached_pan_resolver.clone();
1178 let handle_controller = self.handle_controller.clone();
1179 let node_origin = self.refs.node_origin.clone();
1180 let direct_manipulation = self.refs.direct_manipulation.clone();
1181 let press_track = self.refs.press_track.clone();
1182 let gesture_claimed = self.refs.gesture_claimed.clone();
1183
1184 Some(Rc::new(move |scope| {
1185 let size = scope.size();
1186 if !*is_focused.borrow() {
1188 if let Some(controller) = &handle_controller {
1191 controller.publish(TextFieldHandleMetrics {
1192 focused: false,
1193 direct_manipulation: false,
1194 node_origin: node_origin.get(),
1195 padding_left: 0.0,
1196 padding_top: 0.0,
1197 scroll_offset: 0.0,
1198 line_height: cached_line_height.get(),
1199 glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
1200 wrap_width: measured_wrap_width.get(),
1201 press: None,
1202 });
1203 }
1204 return;
1205 }
1206
1207 let mut primitives = Vec::new();
1208
1209 let text = state.text();
1210 let selection = state.selection();
1211 let padding_left = content_offset.get();
1212 let padding_top = content_y_offset.get();
1213 let line_height = cached_line_height.get();
1216
1217 let (viewport_width, viewport_height) =
1218 content_viewport(measured_size.get(), size, padding_left, padding_top);
1219 let pan = pan_resolver(viewport_width);
1221
1222 if let Some(controller) = &handle_controller {
1225 controller.adopt_gesture_claim(&gesture_claimed);
1226 controller.publish(TextFieldHandleMetrics {
1227 focused: true,
1228 direct_manipulation: direct_manipulation.get(),
1229 node_origin: node_origin.get(),
1230 padding_left,
1231 padding_top,
1232 scroll_offset: pan,
1233 line_height,
1234 glyph_box: crate::text::glyph_line_box(&style, line_height),
1235 wrap_width: measured_wrap_width.get(),
1236 press: press_track.get(),
1237 });
1238 }
1239 let clip_bounds = cranpose_ui_graphics::Rect {
1243 x: padding_left,
1244 y: padding_top,
1245 width: viewport_width,
1246 height: viewport_height,
1247 };
1248
1249 if let Some(comp_range) = state.composition() {
1256 let comp_start = comp_range.min();
1257 let comp_end = comp_range.max();
1258
1259 if comp_start < comp_end && comp_end <= text.len() {
1260 let underline_brush = cranpose_ui_graphics::Brush::solid(
1262 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1263 );
1264 let underline_height: f32 = 2.0;
1265
1266 for line_rect in range_visual_line_rects(
1269 &text,
1270 &style,
1271 node_id.get(),
1272 measured_wrap_width.get(),
1273 padding_left,
1274 padding_top,
1275 pan,
1276 line_height,
1277 comp_start,
1278 comp_end,
1279 ) {
1280 let underline_rect = cranpose_ui_graphics::Rect {
1281 x: line_rect.x,
1282 y: line_rect.y + line_height - underline_height,
1283 width: line_rect.width,
1284 height: underline_height,
1285 };
1286 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1287 primitives.push(DrawPrimitive::Rect {
1288 rect: clipped,
1289 brush: underline_brush.clone(),
1290 stroke: None,
1291 });
1292 }
1293 }
1294 }
1295 }
1296
1297 if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1303 let pos = selection.start.min(text.len());
1304 let (line_index, line_start) = caret_visual_line_for_offset(
1313 &text,
1314 &style,
1315 node_id.get(),
1316 measured_wrap_width.get(),
1317 pos,
1318 crate::text_selection::LineAffinity::Upstream,
1319 );
1320 let cursor_x = crate::text::measure_text(
1321 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1322 &style,
1323 )
1324 .width
1325 + padding_left
1326 - pan;
1327 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1330 let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1331
1332 let cursor_rect = cranpose_ui_graphics::Rect {
1333 x: cursor_x,
1334 y: cursor_y,
1335 width: CURSOR_WIDTH,
1336 height: box_h,
1337 };
1338
1339 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1340 primitives.push(DrawPrimitive::Rect {
1341 rect: clipped,
1342 brush: cursor_brush.clone(),
1343 stroke: None,
1344 });
1345 }
1346 }
1347
1348 scope.push_recorded(primitives);
1349 }))
1350 }
1351
1352 fn create_behind_draw_closure(
1353 &self,
1354 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1355 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1356
1357 let is_focused = self.refs.is_focused.clone();
1358 let state = self.state.clone();
1359 let content_offset = self.refs.content_offset.clone();
1360 let content_y_offset = self.refs.content_y_offset.clone();
1361 let selection_brush = self.selection_brush.clone();
1362 let style = self.style.clone();
1363 let cached_line_height = self.measured_line_height.clone();
1364 let measured_size = self.measured_size.clone();
1365 let measured_wrap_width = self.measured_wrap_width.clone();
1366 let node_id = self.refs.node_id.clone();
1367 let pan_resolver = self.cached_pan_resolver.clone();
1368
1369 Some(Rc::new(move |scope| {
1370 let size = scope.size();
1371 if !*is_focused.borrow() {
1372 return;
1373 }
1374 let selection = state.selection();
1375 if selection.collapsed() {
1376 return;
1377 }
1378 let text = state.text();
1379 let padding_left = content_offset.get();
1380 let padding_top = content_y_offset.get();
1381 let line_height = cached_line_height.get();
1382 let (viewport_width, viewport_height) =
1383 content_viewport(measured_size.get(), size, padding_left, padding_top);
1384 let pan = pan_resolver(viewport_width);
1385 let clip_bounds = cranpose_ui_graphics::Rect {
1386 x: padding_left,
1387 y: padding_top,
1388 width: viewport_width,
1389 height: viewport_height,
1390 };
1391
1392 let mut primitives = Vec::new();
1396 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1400 for sel_rect in range_visual_line_rects(
1401 &text,
1402 &style,
1403 node_id.get(),
1404 measured_wrap_width.get(),
1405 padding_left,
1406 padding_top,
1407 pan,
1408 line_height,
1409 selection.min(),
1410 selection.max(),
1411 ) {
1412 let sel_rect = cranpose_ui_graphics::Rect {
1413 y: sel_rect.y + box_off,
1414 height: box_h,
1415 ..sel_rect
1416 };
1417 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1418 primitives.push(DrawPrimitive::Rect {
1419 rect: clipped,
1420 brush: selection_brush.clone(),
1421 stroke: None,
1422 });
1423 }
1424 }
1425 scope.push_recorded(primitives);
1426 }))
1427 }
1428}
1429
1430impl SemanticsNode for TextFieldModifierNode {
1431 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1432 let text = self.state.text();
1433 config.content_description = Some(text);
1434 config.is_editable_text = true;
1435 config.text_selection = Some(self.state.selection());
1436 }
1437}
1438
1439impl PointerInputNode for TextFieldModifierNode {
1440 fn on_pointer_event(
1441 &mut self,
1442 _context: &mut dyn ModifierNodeContext,
1443 _event: &PointerEvent,
1444 ) -> bool {
1445 false
1456 }
1457
1458 fn hit_test(&self, x: f32, y: f32) -> bool {
1459 let size = self.measured_size.get();
1461 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1462 }
1463
1464 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1465 Some(self.cached_handler.clone())
1467 }
1468}
1469
1470#[derive(Clone)]
1481pub struct TextFieldElement {
1482 state: TextFieldState,
1484 style: TextStyle,
1486 cursor_color: Color,
1488 line_limits: TextFieldLineLimits,
1490 handle_controller: Option<TextFieldHandleController>,
1493}
1494
1495impl TextFieldElement {
1496 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1498 Self {
1499 state,
1500 style,
1501 cursor_color: DEFAULT_CURSOR_COLOR,
1502 line_limits: TextFieldLineLimits::default(),
1503 handle_controller: None,
1504 }
1505 }
1506
1507 pub fn with_cursor_color(mut self, color: Color) -> Self {
1509 self.cursor_color = color;
1510 self
1511 }
1512
1513 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1515 self.line_limits = line_limits;
1516 self
1517 }
1518
1519 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1521 self.handle_controller = Some(controller);
1522 self
1523 }
1524}
1525
1526impl std::fmt::Debug for TextFieldElement {
1527 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1528 f.debug_struct("TextFieldElement")
1529 .field("text", &self.state.text())
1530 .field("style", &self.style)
1531 .field("cursor_color", &self.cursor_color)
1532 .finish()
1533 }
1534}
1535
1536impl Hash for TextFieldElement {
1537 fn hash<H: Hasher>(&self, state: &mut H) {
1538 std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1541 self.cursor_color.0.to_bits().hash(state);
1543 self.cursor_color.1.to_bits().hash(state);
1544 self.cursor_color.2.to_bits().hash(state);
1545 self.cursor_color.3.to_bits().hash(state);
1546 self.style.render_hash().hash(state);
1547 self.line_limits.hash(state);
1548 }
1549}
1550
1551impl PartialEq for TextFieldElement {
1552 fn eq(&self, other: &Self) -> bool {
1553 self.state == other.state
1557 && self.style == other.style
1558 && self.cursor_color == other.cursor_color
1559 && self.line_limits == other.line_limits
1560 }
1561}
1562
1563impl Eq for TextFieldElement {}
1564
1565impl ModifierNodeElement for TextFieldElement {
1566 type Node = TextFieldModifierNode;
1567
1568 fn create(&self) -> Self::Node {
1569 let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1570 .with_cursor_color(self.cursor_color)
1571 .with_line_limits(self.line_limits);
1572 if let Some(controller) = self.handle_controller.clone() {
1573 node = node.with_handle_controller(controller);
1574 }
1575 node
1576 }
1577
1578 fn update(&self, node: &mut Self::Node) {
1579 node.state = self.state.clone();
1581 node.style = self.style.clone();
1582 node.cursor_brush = Brush::solid(self.cursor_color);
1583 node.line_limits = self.line_limits;
1584 node.handle_controller = self.handle_controller.clone();
1585
1586 node.cached_handler = TextFieldModifierNode::create_handler(
1588 node.state.clone(),
1589 node.refs.clone(),
1590 node.line_limits,
1591 self.style.clone(),
1592 );
1593
1594 node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1596 node.state.clone(),
1597 node.refs.clone(),
1598 node.line_limits,
1599 self.style.clone(),
1600 );
1601
1602 if node.update_cached_state() {
1604 }
1607 }
1608
1609 fn capabilities(&self) -> NodeCapabilities {
1610 NodeCapabilities::LAYOUT
1611 | NodeCapabilities::DRAW
1612 | NodeCapabilities::SEMANTICS
1613 | NodeCapabilities::POINTER_INPUT
1614 }
1615
1616 fn always_update(&self) -> bool {
1617 true
1619 }
1620}
1621
1622#[cfg(test)]
1623mod tests {
1624 use super::*;
1625 use crate::text::TextStyle;
1626 use cranpose_core::{DefaultScheduler, Runtime};
1627 use std::sync::Arc;
1628
1629 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1631 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1632 f()
1633 }
1634
1635 #[test]
1636 fn text_field_node_creation() {
1637 let _app_context = crate::render_state::app_context_test_scope();
1638 with_test_runtime(|| {
1639 let state = TextFieldState::new("Hello");
1640 let node = TextFieldModifierNode::new(state, TextStyle::default());
1641 assert_eq!(node.text(), "Hello");
1642 assert!(!node.is_focused());
1643 });
1644 }
1645
1646 #[test]
1651 fn selection_rects_follow_wrapped_visual_lines() {
1652 let _app_context = crate::render_state::app_context_test_scope();
1653 let text = "aaaaa\nbb";
1656 let style = TextStyle::default();
1657 let line_height = 10.0_f32;
1658
1659 let rects = range_visual_line_rects(
1662 text,
1663 &style,
1664 None,
1665 Some(30.0),
1666 0.0,
1667 0.0,
1668 0.0,
1669 line_height,
1670 6,
1671 8,
1672 );
1673 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1674 assert_eq!(
1675 rects[0].y,
1676 2.0 * line_height,
1677 "highlight must land on visual line 2, not logical line 1"
1678 );
1679 assert!(rects[0].width > 0.0);
1680
1681 let spanning = range_visual_line_rects(
1684 text,
1685 &style,
1686 None,
1687 Some(30.0),
1688 0.0,
1689 0.0,
1690 0.0,
1691 line_height,
1692 0,
1693 5,
1694 );
1695 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1696 assert_eq!(spanning[0].y, 0.0);
1697 assert_eq!(spanning[1].y, line_height);
1698 }
1699
1700 #[test]
1705 fn tap_resolves_offset_on_wrapped_visual_line() {
1706 let _app_context = crate::render_state::app_context_test_scope();
1707 let text = "aaaaa\nbb";
1710 let style = TextStyle::default();
1711 let line_height = 10.0_f32;
1712
1713 let off = crate::text::offset_for_position_wrapped(
1716 text,
1717 &style,
1718 None,
1719 Some(30.0),
1720 line_height,
1721 8.0,
1722 22.0,
1723 );
1724 assert!(
1725 (6..=8).contains(&off),
1726 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1727 );
1728
1729 let off1 = crate::text::offset_for_position_wrapped(
1732 text,
1733 &style,
1734 None,
1735 Some(30.0),
1736 line_height,
1737 4.0,
1738 12.0,
1739 );
1740 assert!(
1741 (3..=5).contains(&off1),
1742 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1743 );
1744
1745 let off2 = crate::text::offset_for_position_wrapped(
1747 "hello",
1748 &style,
1749 None,
1750 None,
1751 line_height,
1752 0.0,
1753 0.0,
1754 );
1755 assert_eq!(off2, 0);
1756 }
1757
1758 #[test]
1759 fn text_field_node_focus() {
1760 let _app_context = crate::render_state::app_context_test_scope();
1761 with_test_runtime(|| {
1762 let state = TextFieldState::new("Test");
1763 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1764 assert!(!node.is_focused());
1765
1766 node.set_focused(true);
1767 assert!(node.is_focused());
1768
1769 node.set_focused(false);
1770 assert!(!node.is_focused());
1771 });
1772 }
1773
1774 #[test]
1775 fn text_field_element_creates_node() {
1776 let _app_context = crate::render_state::app_context_test_scope();
1777 with_test_runtime(|| {
1778 let state = TextFieldState::new("Hello World");
1779 let element = TextFieldElement::new(state, TextStyle::default());
1780
1781 let node = element.create();
1782 assert_eq!(node.text(), "Hello World");
1783 });
1784 }
1785
1786 #[test]
1791 fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1792 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1793 use cranpose_ui_graphics::Point;
1794
1795 let _app_context = crate::render_state::app_context_test_scope();
1796 with_test_runtime(|| {
1797 let state = TextFieldState::new("hello world");
1798 let controller = TextFieldHandleController::new();
1799 let mut node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1800 .with_handle_controller(controller.clone());
1801 node.measured_size.set(Size {
1803 width: 120.0,
1804 height: 20.0,
1805 });
1806
1807 let handler = node
1808 .pointer_input_handler()
1809 .expect("field exposes a pointer handler");
1810 let draw = node
1811 .create_draw_closure()
1812 .expect("field exposes a draw closure");
1813 let at = Point { x: 12.0, y: 8.0 };
1814 let size = Size {
1815 width: 120.0,
1816 height: 20.0,
1817 };
1818 let run_draw = || {
1821 let mut scope = crate::draw::command_draw_scope(size);
1822 draw(&mut scope);
1823 };
1824
1825 node.set_focused(true);
1826 run_draw();
1827 let keyboard_metrics = controller
1828 .metrics()
1829 .expect("focused field publishes handle metrics");
1830 assert!(!keyboard_metrics.direct_manipulation);
1831
1832 handler(
1833 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1834 );
1835 run_draw();
1836 let metrics = controller
1837 .metrics()
1838 .expect("focused field publishes handle metrics");
1839 assert!(metrics.focused, "a tap focuses the field");
1840 assert!(
1841 metrics.direct_manipulation,
1842 "a touch tap must expose direct-manipulation handles"
1843 );
1844 assert!(metrics.press.is_some(), "touch must publish the live press");
1845
1846 handler(
1847 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1848 );
1849 run_draw();
1850 let metrics = controller
1851 .metrics()
1852 .expect("focused field publishes handle metrics");
1853 assert!(
1854 metrics.direct_manipulation,
1855 "a mouse tap must expose the same direct-manipulation handles"
1856 );
1857 assert!(metrics.press.is_some(), "mouse must publish the live press");
1858
1859 handler(
1860 PointerEvent::new(PointerEventKind::Down, at, at)
1861 .with_source(PointerSource::Stylus),
1862 );
1863 run_draw();
1864 let metrics = controller
1865 .metrics()
1866 .expect("focused field publishes handle metrics");
1867 assert!(
1868 metrics.direct_manipulation,
1869 "a stylus contact must expose the same direct-manipulation handles"
1870 );
1871 assert!(
1872 metrics.press.is_some(),
1873 "stylus must publish the live press"
1874 );
1875
1876 crate::text_field_focus::clear_focus();
1877 });
1878 }
1879
1880 #[test]
1888 fn double_tap_selects_the_word_under_the_finger() {
1889 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1890 use cranpose_ui_graphics::Point;
1891
1892 let _app_context = crate::render_state::app_context_test_scope();
1893 with_test_runtime(|| {
1894 let state = TextFieldState::new("hello world");
1895 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1896 node.measured_size.set(Size {
1897 width: 200.0,
1898 height: 20.0,
1899 });
1900 let handler = node
1901 .pointer_input_handler()
1902 .expect("field exposes a pointer handler");
1903
1904 let at = Point { x: 2.0, y: 8.0 };
1907 handler(
1908 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1909 );
1910 handler(
1911 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1912 );
1913
1914 let selection = state.selection();
1915 assert!(
1916 !selection.collapsed(),
1917 "a double tap must produce a (word) selection, got {selection:?}"
1918 );
1919 let selected = &state.text()[selection.min()..selection.max()];
1920 assert_eq!(
1921 selected, "hello",
1922 "double tap should select the whole word under the finger"
1923 );
1924
1925 crate::text_field_focus::clear_focus();
1926 });
1927 }
1928
1929 #[test]
1933 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1934 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1935 use cranpose_ui_graphics::Point;
1936
1937 let _app_context = crate::render_state::app_context_test_scope();
1938 with_test_runtime(|| {
1939 let text = "alpha beta\ngamma delta\n\nsecond para";
1942 let state = TextFieldState::new(text);
1943 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1944 .with_line_limits(TextFieldLineLimits::MultiLine {
1945 min_lines: 1,
1946 max_lines: usize::MAX,
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.clone(), 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.clone(), TextStyle::default())
2059 .with_line_limits(TextFieldLineLimits::MultiLine {
2060 min_lines: 1,
2061 max_lines: usize::MAX,
2062 });
2063 node.measured_size.set(Size {
2064 width: 400.0,
2065 height: 80.0,
2066 });
2067 let handler = node
2068 .pointer_input_handler()
2069 .expect("field exposes a pointer handler");
2070
2071 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
2073
2074 let at = Point { x: 2.0, y: 4.0 };
2075 let selected = |state: &TextFieldState| {
2076 let s = state.selection();
2077 state.text()[s.min()..s.max()].to_string()
2078 };
2079 let slow_tap = || {
2082 node.refs.last_click_time.set(None);
2083 handler(
2084 PointerEvent::new(PointerEventKind::Down, at, at)
2085 .with_source(PointerSource::Touch),
2086 );
2087 };
2088
2089 slow_tap(); assert_eq!(
2091 selected(&state),
2092 "alpha",
2093 "tap inside selection grabs the word"
2094 );
2095 slow_tap(); assert_eq!(
2097 selected(&state),
2098 "alpha beta",
2099 "same-spot tap grows to the line even after the timeout"
2100 );
2101 slow_tap(); assert_eq!(
2103 selected(&state),
2104 "alpha beta\ngamma delta",
2105 "same-spot tap grows to the paragraph"
2106 );
2107 slow_tap(); assert_eq!(
2109 selected(&state),
2110 "alpha",
2111 "same-spot tap cycles back to the word"
2112 );
2113
2114 crate::text_field_focus::clear_focus();
2115 });
2116 }
2117
2118 #[test]
2119 fn text_field_element_equality() {
2120 let _app_context = crate::render_state::app_context_test_scope();
2121 with_test_runtime(|| {
2122 let state1 = TextFieldState::new("Hello");
2123 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
2126 let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
2132 assert_ne!(elem1, elem3, "Different states should not be equal");
2133 });
2134 }
2135
2136 #[test]
2137 fn text_field_element_update_refreshes_existing_node_style() {
2138 let _app_context = crate::render_state::app_context_test_scope();
2139 with_test_runtime(|| {
2140 let state = TextFieldState::new("themed text");
2141 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
2142 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
2143 ..crate::text::SpanStyle::default()
2144 });
2145 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
2146 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
2147 ..crate::text::SpanStyle::default()
2148 });
2149 let initial = TextFieldElement::new(state.clone(), dark_style);
2150 let updated = TextFieldElement::new(state, light_style.clone());
2151 let mut node = initial.create();
2152
2153 updated.update(&mut node);
2154
2155 assert_eq!(node.text(), "themed text");
2156 assert_eq!(node.style(), &light_style);
2157 });
2158 }
2159
2160 #[test]
2165 fn multiline_field_measures_wrapped_height() {
2166 let _app_context = crate::render_state::app_context_test_scope();
2167 with_test_runtime(|| {
2168 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
2170 let node = TextFieldModifierNode::new(state, TextStyle::default());
2171 assert!(
2172 !node.line_limits().is_single_line(),
2173 "default fields are multi-line"
2174 );
2175
2176 let natural = node.measure_text_content(None);
2177 let wrapped = node.measure_text_content(node.wrap_width(20.0));
2178
2179 assert!(
2180 wrapped.height > natural.height,
2181 "wrapped multi-line height {} must exceed the single-line height {}",
2182 wrapped.height,
2183 natural.height
2184 );
2185 });
2186 }
2187
2188 #[test]
2191 fn single_line_field_never_wraps() {
2192 let _app_context = crate::render_state::app_context_test_scope();
2193 with_test_runtime(|| {
2194 let state = TextFieldState::new("abcd ".repeat(40));
2195 let node = TextFieldModifierNode::new(state, TextStyle::default())
2196 .with_line_limits(TextFieldLineLimits::SingleLine);
2197 assert_eq!(
2198 node.wrap_width(20.0),
2199 None,
2200 "single-line fields must not wrap"
2201 );
2202 });
2203 }
2204
2205 #[test]
2211 fn test_cursor_x_position_calculation() {
2212 let _app_context = crate::render_state::app_context_test_scope();
2213 with_test_runtime(|| {
2214 let style = crate::text::TextStyle::default();
2216
2217 let empty_width =
2219 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
2220 assert!(
2221 empty_width.abs() < 0.1,
2222 "Empty text should have 0 width, got {}",
2223 empty_width
2224 );
2225
2226 let hi_width =
2228 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
2229 assert!(
2230 hi_width > 0.0,
2231 "Text 'Hi' should have positive width: {}",
2232 hi_width
2233 );
2234
2235 let h_width =
2237 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
2238 assert!(h_width > 0.0, "Text 'H' should have positive width");
2239 assert!(
2240 h_width < hi_width,
2241 "'H' width {} should be less than 'Hi' width {}",
2242 h_width,
2243 hi_width
2244 );
2245
2246 let state = TextFieldState::new("Hi");
2248 assert_eq!(
2249 state.selection().start,
2250 2,
2251 "Cursor should be at position 2 (end of 'Hi')"
2252 );
2253
2254 let text = state.text();
2256 let cursor_pos = state.selection().start;
2257 let text_before_cursor = &text[..cursor_pos.min(text.len())];
2258 assert_eq!(text_before_cursor, "Hi");
2259
2260 let cursor_x = crate::text::measure_text(
2262 &crate::text::AnnotatedString::from(text_before_cursor),
2263 &style,
2264 )
2265 .width;
2266 assert!(
2267 (cursor_x - hi_width).abs() < 0.1,
2268 "Cursor x {} should equal 'Hi' width {}",
2269 cursor_x,
2270 hi_width
2271 );
2272 });
2273 }
2274
2275 #[test]
2277 fn test_focused_node_creates_cursor() {
2278 let _app_context = crate::render_state::app_context_test_scope();
2279 with_test_runtime(|| {
2280 let state = TextFieldState::new("Test");
2281 let element = TextFieldElement::new(state.clone(), TextStyle::default());
2282 let node = element.create();
2283
2284 assert!(!node.is_focused());
2286
2287 *node.refs.is_focused.borrow_mut() = true;
2289 assert!(node.is_focused());
2290
2291 assert_eq!(node.text(), "Test");
2293
2294 assert_eq!(node.selection().start, 4);
2296 });
2297 }
2298}