1use cranpose_core::{mutableStateOf, MutableState};
20use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
21use cranpose_foundation::{
22 Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
23 LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
24 NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode, PointerSource,
25 SemanticsConfiguration, SemanticsNode, Size,
26};
27use cranpose_ui_graphics::{Brush, Color, Point};
28use std::cell::{Cell, RefCell};
29use std::hash::{Hash, Hasher};
30use std::rc::Rc;
31
32#[derive(Clone, Copy, PartialEq, Debug)]
37pub struct TextFieldHandleMetrics {
38 pub focused: bool,
39 pub touch: bool,
40 pub node_origin: Point,
42 pub padding_left: f32,
43 pub padding_top: f32,
44 pub scroll_offset: f32,
45 pub line_height: f32,
46}
47
48#[derive(Clone)]
53pub struct TextFieldHandleController {
54 inner: Rc<TextFieldHandleControllerInner>,
55}
56
57impl PartialEq for TextFieldHandleController {
58 fn eq(&self, other: &Self) -> bool {
59 Rc::ptr_eq(&self.inner, &other.inner)
60 }
61}
62
63struct TextFieldHandleControllerInner {
64 metrics: Cell<Option<TextFieldHandleMetrics>>,
65 revision: MutableState<u64>,
66}
67
68impl TextFieldHandleController {
69 pub fn new() -> Self {
72 Self {
73 inner: Rc::new(TextFieldHandleControllerInner {
74 metrics: Cell::new(None),
75 revision: mutableStateOf(0u64),
76 }),
77 }
78 }
79
80 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
83 if self.inner.metrics.get() != Some(metrics) {
84 self.inner.metrics.set(Some(metrics));
85 self.inner
86 .revision
87 .update(|value| *value = value.wrapping_add(1));
88 }
89 }
90
91 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
94 let _ = self.inner.revision.value();
95 self.inner.metrics.get()
96 }
97}
98
99impl Default for TextFieldHandleController {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
107
108const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
110
111const DEFAULT_LINE_HEIGHT: f32 = 20.0;
113
114const CURSOR_WIDTH: f32 = 2.0;
116
117pub(crate) fn compute_horizontal_scroll_offset(
128 current_offset: f32,
129 cursor_x: f32,
130 text_width: f32,
131 viewport_width: f32,
132) -> f32 {
133 if viewport_width <= 0.0 {
134 return 0.0;
135 }
136 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
137 let mut offset = current_offset.clamp(0.0, max_offset);
138 let visible_end = offset + viewport_width - CURSOR_WIDTH;
139 if cursor_x > visible_end {
140 offset = cursor_x - viewport_width + CURSOR_WIDTH;
142 } else if cursor_x < offset {
143 offset = cursor_x;
145 }
146 offset.clamp(0.0, max_offset)
147}
148
149pub(crate) fn intersect_rect(
154 rect: cranpose_ui_graphics::Rect,
155 bounds: cranpose_ui_graphics::Rect,
156) -> Option<cranpose_ui_graphics::Rect> {
157 let x0 = rect.x.max(bounds.x);
158 let y0 = rect.y.max(bounds.y);
159 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
160 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
161 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
162 x: x0,
163 y: y0,
164 width: x1 - x0,
165 height: y1 - y0,
166 })
167}
168
169pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
172
173#[derive(Clone)]
179pub(crate) struct TextFieldRefs {
180 pub is_focused: Rc<RefCell<bool>>,
182 pub content_offset: Rc<Cell<f32>>,
184 pub content_y_offset: Rc<Cell<f32>>,
186 pub drag_anchor: Rc<Cell<Option<usize>>>,
188 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
190 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
192 pub click_count: Rc<Cell<u8>>,
194 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
196 pub scroll_offset: Rc<Cell<f32>>,
199 pub last_pointer_source: Rc<Cell<PointerSource>>,
203 pub node_origin: Rc<Cell<Point>>,
207}
208
209impl TextFieldRefs {
210 pub fn new() -> Self {
212 Self {
213 is_focused: Rc::new(RefCell::new(false)),
214 content_offset: Rc::new(Cell::new(0.0_f32)),
215 content_y_offset: Rc::new(Cell::new(0.0_f32)),
216 drag_anchor: Rc::new(Cell::new(None::<usize>)),
217 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
218 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
219 click_count: Rc::new(Cell::new(0_u8)),
220 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
221 scroll_offset: Rc::new(Cell::new(0.0_f32)),
222 last_pointer_source: Rc::new(Cell::new(PointerSource::Unknown)),
223 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
224 }
225 }
226}
227
228use crate::text::TextStyle; pub struct TextFieldModifierNode {
237 state: TextFieldState,
239 refs: TextFieldRefs,
241 style: TextStyle, cursor_brush: Brush,
245 selection_brush: Brush,
247 line_limits: TextFieldLineLimits,
249 cached_text: String,
251 cached_selection: TextRange,
253 node_state: NodeState,
255 measured_size: Rc<Cell<Size>>,
257 measured_line_height: Rc<Cell<f32>>,
259 cached_handler: Rc<dyn Fn(PointerEvent)>,
261 cached_pan_resolver: TextPanResolver,
263 handle_controller: Option<TextFieldHandleController>,
267}
268
269impl std::fmt::Debug for TextFieldModifierNode {
270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271 f.debug_struct("TextFieldModifierNode")
272 .field("text", &self.state.text())
273 .field("style", &self.style)
274 .field("is_focused", &*self.refs.is_focused.borrow())
275 .finish()
276 }
277}
278
279use crate::text_field_handler::TextFieldHandler;
281
282impl TextFieldModifierNode {
283 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
285 let value = state.value();
286 let refs = TextFieldRefs::new();
287 let line_limits = TextFieldLineLimits::default();
288 let cached_handler =
289 Self::create_handler(state.clone(), refs.clone(), line_limits, style.clone());
290 let cached_pan_resolver =
291 Self::create_pan_resolver(state.clone(), refs.clone(), line_limits, style.clone());
292
293 Self {
294 state,
295 refs,
296 style,
297 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
298 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
299 line_limits,
300 cached_text: value.text,
301 cached_selection: value.selection,
302 node_state: NodeState::new(),
303 measured_size: Rc::new(Cell::new(Size {
304 width: 0.0,
305 height: 0.0,
306 })),
307 measured_line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
308 cached_handler,
309 cached_pan_resolver,
310 handle_controller: None,
311 }
312 }
313
314 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
316 self.line_limits = line_limits;
317 self.cached_pan_resolver = Self::create_pan_resolver(
318 self.state.clone(),
319 self.refs.clone(),
320 line_limits,
321 self.style.clone(),
322 );
323 self
324 }
325
326 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
328 self.handle_controller = Some(controller);
329 self
330 }
331
332 fn create_pan_resolver(
340 state: TextFieldState,
341 refs: TextFieldRefs,
342 line_limits: TextFieldLineLimits,
343 style: TextStyle,
344 ) -> TextPanResolver {
345 Rc::new(move |viewport_width: f32| {
346 if !line_limits.is_single_line() {
347 refs.scroll_offset.set(0.0);
349 return 0.0;
350 }
351 let text = state.text();
352 let pos = state.selection().start.min(text.len());
353 let text_width = crate::text::measure_text(
354 &crate::text::AnnotatedString::from(text.as_str()),
355 &style,
356 )
357 .width;
358 let cursor_x = crate::text::measure_text(
359 &crate::text::AnnotatedString::from(&text[..pos]),
360 &style,
361 )
362 .width;
363 let offset = compute_horizontal_scroll_offset(
364 refs.scroll_offset.get(),
365 cursor_x,
366 text_width,
367 viewport_width,
368 );
369 refs.scroll_offset.set(offset);
370 offset
371 })
372 }
373
374 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
379 self.line_limits
380 .is_single_line()
381 .then(|| self.cached_pan_resolver.clone())
382 }
383
384 pub fn scroll_offset(&self) -> f32 {
386 self.refs.scroll_offset.get()
387 }
388
389 pub fn line_limits(&self) -> TextFieldLineLimits {
391 self.line_limits
392 }
393
394 fn create_handler(
396 state: TextFieldState,
397 refs: TextFieldRefs,
398 line_limits: TextFieldLineLimits,
399 style: TextStyle, ) -> Rc<dyn Fn(PointerEvent)> {
401 use crate::text_selection::{
404 classify_tap_count, find_line_boundaries, find_paragraph_boundaries,
405 tap_selection_granularity, SelectionGranularity, MULTI_TAP_SLOP_PX,
406 MULTI_TAP_TIMEOUT_MS,
407 };
408 use crate::word_boundaries::find_word_boundaries;
409
410 Rc::new(move |event: PointerEvent| {
411 refs.node_origin.set(Point {
418 x: event.global_position.x - event.position.x,
419 y: event.global_position.y - event.position.y,
420 });
421
422 let click_x =
426 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
427 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
428
429 match event.kind {
430 PointerEventKind::Down => {
431 refs.last_pointer_source.set(event.source);
435
436 let handler =
438 TextFieldHandler::new(state.clone(), refs.node_id.get(), line_limits);
439 crate::text_field_focus::request_focus(refs.is_focused.clone(), handler);
440
441 let now = web_time::Instant::now();
442 let text = state.text();
443 let pos = crate::text::get_offset_for_position(
444 &crate::text::AnnotatedString::from(text.as_str()),
445 &style,
446 click_x,
447 click_y,
448 );
449
450 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
455 let count = refs.click_count.get();
456 (count > 0).then_some((count, px, py))
457 });
458 let elapsed_ms = refs
459 .last_click_time
460 .get()
461 .map(|last| now.duration_since(last).as_millis())
462 .unwrap_or(u128::MAX);
463 let tap_count = classify_tap_count(
464 previous,
465 elapsed_ms,
466 event.position.x,
467 event.position.y,
468 MULTI_TAP_TIMEOUT_MS,
469 MULTI_TAP_SLOP_PX,
470 );
471
472 let selection = state.selection();
479 let effective_count = if tap_count == 1
480 && !selection.collapsed()
481 && pos >= selection.min()
482 && pos <= selection.max()
483 {
484 2
485 } else {
486 tap_count
487 };
488
489 match tap_selection_granularity(effective_count) {
490 SelectionGranularity::Paragraph => {
491 let (start, end) = find_paragraph_boundaries(&text, pos);
493 state.edit(|buffer| {
494 buffer.select(TextRange::new(start, end));
495 });
496 refs.drag_anchor.set(Some(start));
497 }
498 SelectionGranularity::Line => {
499 let (line_start, line_end) = find_line_boundaries(&text, pos);
501 state.edit(|buffer| {
502 buffer.select(TextRange::new(line_start, line_end));
503 });
504 refs.drag_anchor.set(Some(line_start));
505 }
506 SelectionGranularity::Word => {
507 let (word_start, word_end) = find_word_boundaries(&text, pos);
510 state.edit(|buffer| {
511 buffer.select(TextRange::new(word_start, word_end));
512 });
513 refs.drag_anchor.set(Some(word_start));
514 }
515 SelectionGranularity::Caret => {
516 refs.drag_anchor.set(Some(pos));
518 state.edit(|buffer| {
519 buffer.place_cursor_before_char(pos);
520 });
521 }
522 }
523
524 refs.click_count.set(effective_count);
525 refs.last_click_time.set(Some(now));
526 refs.last_click_pos
527 .set(Some((event.position.x, event.position.y)));
528 event.consume();
529 }
530 PointerEventKind::Move => {
531 if let Some(anchor) = refs.drag_anchor.get() {
533 if *refs.is_focused.borrow() {
534 let text = state.text();
535 let current_pos = crate::text::get_offset_for_position(
536 &crate::text::AnnotatedString::from(text.as_str()),
537 &style,
538 click_x,
539 click_y,
540 );
541
542 state.set_selection(TextRange::new(anchor, current_pos));
544
545 crate::request_render_invalidation();
547
548 event.consume();
549 }
550 }
551 }
552 PointerEventKind::Up => {
553 refs.drag_anchor.set(None);
555 }
556 _ => {}
557 }
558 })
559 }
560
561 pub fn with_cursor_color(mut self, color: Color) -> Self {
563 self.cursor_brush = Brush::solid(color);
564 self
565 }
566
567 pub fn set_focused(&mut self, focused: bool) {
569 let current = *self.refs.is_focused.borrow();
570 if current != focused {
571 *self.refs.is_focused.borrow_mut() = focused;
572 }
573 }
574
575 pub fn is_focused(&self) -> bool {
577 *self.refs.is_focused.borrow()
578 }
579
580 pub fn is_focused_rc(&self) -> Rc<RefCell<bool>> {
582 self.refs.is_focused.clone()
583 }
584
585 pub fn content_offset_rc(&self) -> Rc<Cell<f32>> {
587 self.refs.content_offset.clone()
588 }
589
590 pub fn content_y_offset_rc(&self) -> Rc<Cell<f32>> {
592 self.refs.content_y_offset.clone()
593 }
594
595 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
608 self.refs.node_origin.clone()
609 }
610
611 pub fn text(&self) -> String {
613 self.state.text()
614 }
615
616 pub fn style(&self) -> &TextStyle {
617 &self.style
618 }
619
620 pub fn selection(&self) -> TextRange {
622 self.state.selection()
623 }
624
625 pub fn cursor_brush(&self) -> Brush {
627 self.cursor_brush.clone()
628 }
629
630 pub fn selection_brush(&self) -> Brush {
632 self.selection_brush.clone()
633 }
634
635 pub fn insert_text(&mut self, text: &str) {
637 self.state.edit(|buffer| {
638 buffer.insert(text);
639 });
640 }
641
642 pub fn copy_selection(&self) -> Option<String> {
645 self.state.copy_selection()
646 }
647
648 pub fn cut_selection(&mut self) -> Option<String> {
651 let text = self.copy_selection();
652 if text.is_some() {
653 self.state.edit(|buffer| {
654 buffer.delete(buffer.selection());
655 });
656 }
657 text
658 }
659
660 pub fn get_state(&self) -> cranpose_foundation::text::TextFieldState {
663 self.state.clone()
664 }
665
666 pub fn set_content_offset(&self, offset: f32) {
669 self.refs.content_offset.set(offset);
670 }
671
672 pub fn set_content_y_offset(&self, offset: f32) {
675 self.refs.content_y_offset.set(offset);
676 }
677
678 fn wrap_width(&self, available_width: f32) -> Option<f32> {
685 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
686 .then_some(available_width)
687 }
688
689 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
695 let text = self.state.text();
696 let node_id = self.refs.node_id.get();
697 let annotated = crate::text::AnnotatedString::from(text.as_str());
698 let metrics = match wrap_width {
699 Some(max_width) => crate::text::measure_text_with_options_for_node(
700 node_id,
701 &annotated,
702 &self.style,
703 crate::text::TextLayoutOptions::default(),
704 Some(max_width),
705 ),
706 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
707 };
708 self.measured_line_height.set(metrics.line_height);
709 Size {
710 width: metrics.width,
711 height: metrics.height,
712 }
713 }
714
715 fn update_cached_state(&mut self) -> bool {
717 let value = self.state.value();
718 let text_changed = value.text != self.cached_text;
719 let selection_changed = value.selection != self.cached_selection;
720
721 if text_changed {
722 self.cached_text = value.text;
723 }
724 if selection_changed {
725 self.cached_selection = value.selection;
726 }
727
728 text_changed || selection_changed
729 }
730
731 pub fn position_cursor_at_offset(&self, x_offset: f32) {
734 let text = self.state.text();
735 if text.is_empty() {
736 self.state.edit(|buffer| {
737 buffer.place_cursor_at_start();
738 });
739 return;
740 }
741
742 let byte_offset = crate::text::get_offset_for_position(
745 &crate::text::AnnotatedString::from(text.as_str()),
746 &self.style,
747 x_offset + self.refs.scroll_offset.get(),
748 0.0,
749 );
750
751 self.state.edit(|buffer| {
752 buffer.place_cursor_before_char(byte_offset);
753 });
754 }
755
756 }
760
761impl DelegatableNode for TextFieldModifierNode {
762 fn node_state(&self) -> &NodeState {
763 &self.node_state
764 }
765}
766
767impl ModifierNode for TextFieldModifierNode {
768 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
769 self.refs.node_id.set(context.node_id());
771
772 context.invalidate(InvalidationKind::Layout);
773 context.invalidate(InvalidationKind::Draw);
774 context.invalidate(InvalidationKind::Semantics);
775 }
776
777 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
778 Some(self)
779 }
780
781 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
782 Some(self)
783 }
784
785 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
786 Some(self)
787 }
788
789 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
790 Some(self)
791 }
792
793 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
794 Some(self)
795 }
796
797 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
798 Some(self)
799 }
800
801 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
802 Some(self)
803 }
804
805 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
806 Some(self)
807 }
808}
809
810impl LayoutModifierNode for TextFieldModifierNode {
811 fn measure(
812 &self,
813 _context: &mut dyn ModifierNodeContext,
814 _measurable: &dyn Measurable,
815 constraints: Constraints,
816 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
817 let text_size = self.measure_text_content(self.wrap_width(constraints.max_width));
821
822 let min_height = if text_size.height < 1.0 {
824 DEFAULT_LINE_HEIGHT
825 } else {
826 text_size.height
827 };
828
829 let width = text_size
831 .width
832 .max(constraints.min_width)
833 .min(constraints.max_width);
834 let height = min_height
835 .max(constraints.min_height)
836 .min(constraints.max_height);
837
838 let size = Size { width, height };
839 self.measured_size.set(size);
840
841 let _ = (self.cached_pan_resolver)(size.width);
844
845 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
846 }
847
848 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
849 self.measure_text_content(None).width
850 }
851
852 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
853 self.measure_text_content(None).width
854 }
855
856 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
857 self.measure_text_content(self.wrap_width(width))
858 .height
859 .max(DEFAULT_LINE_HEIGHT)
860 }
861
862 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
863 self.measure_text_content(self.wrap_width(width))
864 .height
865 .max(DEFAULT_LINE_HEIGHT)
866 }
867}
868
869impl DrawModifierNode for TextFieldModifierNode {
870 fn draw(&self, _draw_scope: &mut dyn DrawScope) {
871 }
875
876 fn create_draw_closure(
877 &self,
878 ) -> Option<Rc<dyn Fn(cranpose_foundation::Size) -> Vec<cranpose_ui_graphics::DrawPrimitive>>>
879 {
880 use cranpose_ui_graphics::DrawPrimitive;
881
882 let is_focused = self.refs.is_focused.clone();
884 let state = self.state.clone();
885 let content_offset = self.refs.content_offset.clone();
886 let content_y_offset = self.refs.content_y_offset.clone();
887 let cursor_brush = self.cursor_brush.clone();
888 let selection_brush = self.selection_brush.clone();
889 let style = self.style.clone();
890 let cached_line_height = self.measured_line_height.clone();
891 let measured_size = self.measured_size.clone();
892 let pan_resolver = self.cached_pan_resolver.clone();
893 let handle_controller = self.handle_controller.clone();
894 let node_origin = self.refs.node_origin.clone();
895 let last_pointer_source = self.refs.last_pointer_source.clone();
896
897 Some(Rc::new(move |size| {
898 if !*is_focused.borrow() {
900 if let Some(controller) = &handle_controller {
903 controller.publish(TextFieldHandleMetrics {
904 focused: false,
905 touch: false,
906 node_origin: node_origin.get(),
907 padding_left: 0.0,
908 padding_top: 0.0,
909 scroll_offset: 0.0,
910 line_height: cached_line_height.get(),
911 });
912 }
913 return vec![];
914 }
915
916 let mut primitives = Vec::new();
917
918 let text = state.text();
919 let selection = state.selection();
920 let padding_left = content_offset.get();
921 let padding_top = content_y_offset.get();
922 let line_height = cached_line_height.get();
925
926 let measured = measured_size.get();
929 let viewport_width = if measured.width > 0.0 {
930 measured.width
931 } else {
932 (size.width - padding_left).max(0.0)
933 };
934 let viewport_height = if measured.height > 0.0 {
935 measured.height
936 } else {
937 (size.height - padding_top).max(0.0)
938 };
939 let pan = pan_resolver(viewport_width);
941
942 if let Some(controller) = &handle_controller {
945 controller.publish(TextFieldHandleMetrics {
946 focused: true,
947 touch: last_pointer_source.get().is_touch_like(),
948 node_origin: node_origin.get(),
949 padding_left,
950 padding_top,
951 scroll_offset: pan,
952 line_height,
953 });
954 }
955 let clip_bounds = cranpose_ui_graphics::Rect {
959 x: padding_left,
960 y: padding_top,
961 width: viewport_width,
962 height: viewport_height,
963 };
964
965 if !selection.collapsed() {
967 let sel_start = selection.min();
968 let sel_end = selection.max();
969
970 let lines: Vec<&str> = text.split('\n').collect();
971 let mut byte_offset: usize = 0;
972
973 for (line_idx, line) in lines.iter().enumerate() {
974 let line_start = byte_offset;
975 let line_end = byte_offset + line.len();
976
977 if sel_end > line_start && sel_start < line_end {
978 let sel_start_in_line = sel_start.saturating_sub(line_start);
979 let sel_end_in_line = (sel_end - line_start).min(line.len());
980
981 let sel_start_x = crate::text::measure_text(
982 &crate::text::AnnotatedString::from(&line[..sel_start_in_line]),
983 &style,
984 )
985 .width
986 + padding_left
987 - pan;
988 let sel_end_x = crate::text::measure_text(
989 &crate::text::AnnotatedString::from(&line[..sel_end_in_line]),
990 &style,
991 )
992 .width
993 + padding_left
994 - pan;
995 let sel_width = sel_end_x - sel_start_x;
996
997 if sel_width > 0.0 {
998 let sel_rect = cranpose_ui_graphics::Rect {
999 x: sel_start_x,
1000 y: padding_top + line_idx as f32 * line_height,
1001 width: sel_width,
1002 height: line_height,
1003 };
1004 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1005 primitives.push(DrawPrimitive::Rect {
1006 rect: clipped,
1007 brush: selection_brush.clone(),
1008 });
1009 }
1010 }
1011 }
1012 byte_offset = line_end + 1;
1013 }
1014 }
1015
1016 if let Some(comp_range) = state.composition() {
1019 let comp_start = comp_range.min();
1020 let comp_end = comp_range.max();
1021
1022 if comp_start < comp_end && comp_end <= text.len() {
1023 let lines: Vec<&str> = text.split('\n').collect();
1024 let mut byte_offset: usize = 0;
1025
1026 let underline_brush = cranpose_ui_graphics::Brush::solid(
1028 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
1029 );
1030 let underline_height: f32 = 2.0;
1031
1032 for (line_idx, line) in lines.iter().enumerate() {
1033 let line_start = byte_offset;
1034 let line_end = byte_offset + line.len();
1035
1036 if comp_end > line_start && comp_start < line_end {
1038 let comp_start_in_line = comp_start.saturating_sub(line_start);
1039 let comp_end_in_line = (comp_end - line_start).min(line.len());
1040
1041 let comp_start_in_line = if line.is_char_boundary(comp_start_in_line) {
1043 comp_start_in_line
1044 } else {
1045 0
1046 };
1047 let comp_end_in_line = if line.is_char_boundary(comp_end_in_line) {
1048 comp_end_in_line
1049 } else {
1050 line.len()
1051 };
1052
1053 let comp_start_x = crate::text::measure_text(
1054 &crate::text::AnnotatedString::from(&line[..comp_start_in_line]),
1055 &style,
1056 )
1057 .width
1058 + padding_left
1059 - pan;
1060 let comp_end_x = crate::text::measure_text(
1061 &crate::text::AnnotatedString::from(&line[..comp_end_in_line]),
1062 &style,
1063 )
1064 .width
1065 + padding_left
1066 - pan;
1067 let comp_width = comp_end_x - comp_start_x;
1068
1069 if comp_width > 0.0 {
1070 let underline_rect = cranpose_ui_graphics::Rect {
1072 x: comp_start_x,
1073 y: padding_top + (line_idx as f32 + 1.0) * line_height
1074 - underline_height,
1075 width: comp_width,
1076 height: underline_height,
1077 };
1078 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1079 primitives.push(DrawPrimitive::Rect {
1080 rect: clipped,
1081 brush: underline_brush.clone(),
1082 });
1083 }
1084 }
1085 }
1086 byte_offset = line_end + 1;
1087 }
1088 }
1089 }
1090
1091 if crate::cursor_animation::is_cursor_visible() {
1093 let pos = selection.start.min(text.len());
1094 let text_before = &text[..pos];
1095 let line_index = text_before.matches('\n').count();
1096 let line_start = text_before.rfind('\n').map(|i| i + 1).unwrap_or(0);
1097 let cursor_x = crate::text::measure_text(
1098 &crate::text::AnnotatedString::from(&text_before[line_start..]),
1099 &style,
1100 )
1101 .width
1102 + padding_left
1103 - pan;
1104 let cursor_y = padding_top + line_index as f32 * line_height;
1105
1106 let cursor_rect = cranpose_ui_graphics::Rect {
1107 x: cursor_x,
1108 y: cursor_y,
1109 width: CURSOR_WIDTH,
1110 height: line_height,
1111 };
1112
1113 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1114 primitives.push(DrawPrimitive::Rect {
1115 rect: clipped,
1116 brush: cursor_brush.clone(),
1117 });
1118 }
1119 }
1120
1121 primitives
1122 }))
1123 }
1124}
1125
1126impl SemanticsNode for TextFieldModifierNode {
1127 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1128 let text = self.state.text();
1129 config.content_description = Some(text);
1130 config.is_editable_text = true;
1131 config.text_selection = Some(self.state.selection());
1132 }
1133}
1134
1135impl PointerInputNode for TextFieldModifierNode {
1136 fn on_pointer_event(
1137 &mut self,
1138 _context: &mut dyn ModifierNodeContext,
1139 _event: &PointerEvent,
1140 ) -> bool {
1141 false
1152 }
1153
1154 fn hit_test(&self, x: f32, y: f32) -> bool {
1155 let size = self.measured_size.get();
1157 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1158 }
1159
1160 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1161 Some(self.cached_handler.clone())
1163 }
1164}
1165
1166#[derive(Clone)]
1177pub struct TextFieldElement {
1178 state: TextFieldState,
1180 style: TextStyle,
1182 cursor_color: Color,
1184 line_limits: TextFieldLineLimits,
1186 handle_controller: Option<TextFieldHandleController>,
1189}
1190
1191impl TextFieldElement {
1192 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1194 Self {
1195 state,
1196 style,
1197 cursor_color: DEFAULT_CURSOR_COLOR,
1198 line_limits: TextFieldLineLimits::default(),
1199 handle_controller: None,
1200 }
1201 }
1202
1203 pub fn with_cursor_color(mut self, color: Color) -> Self {
1205 self.cursor_color = color;
1206 self
1207 }
1208
1209 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1211 self.line_limits = line_limits;
1212 self
1213 }
1214
1215 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1217 self.handle_controller = Some(controller);
1218 self
1219 }
1220}
1221
1222impl std::fmt::Debug for TextFieldElement {
1223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1224 f.debug_struct("TextFieldElement")
1225 .field("text", &self.state.text())
1226 .field("style", &self.style)
1227 .field("cursor_color", &self.cursor_color)
1228 .finish()
1229 }
1230}
1231
1232impl Hash for TextFieldElement {
1233 fn hash<H: Hasher>(&self, state: &mut H) {
1234 std::ptr::hash(std::rc::Rc::as_ptr(&self.state.inner), state);
1237 self.cursor_color.0.to_bits().hash(state);
1239 self.cursor_color.1.to_bits().hash(state);
1240 self.cursor_color.2.to_bits().hash(state);
1241 self.cursor_color.3.to_bits().hash(state);
1242 self.style.render_hash().hash(state);
1243 self.line_limits.hash(state);
1244 }
1245}
1246
1247impl PartialEq for TextFieldElement {
1248 fn eq(&self, other: &Self) -> bool {
1249 self.state == other.state
1253 && self.style == other.style
1254 && self.cursor_color == other.cursor_color
1255 && self.line_limits == other.line_limits
1256 }
1257}
1258
1259impl Eq for TextFieldElement {}
1260
1261impl ModifierNodeElement for TextFieldElement {
1262 type Node = TextFieldModifierNode;
1263
1264 fn create(&self) -> Self::Node {
1265 let mut node = TextFieldModifierNode::new(self.state.clone(), self.style.clone())
1266 .with_cursor_color(self.cursor_color)
1267 .with_line_limits(self.line_limits);
1268 if let Some(controller) = self.handle_controller.clone() {
1269 node = node.with_handle_controller(controller);
1270 }
1271 node
1272 }
1273
1274 fn update(&self, node: &mut Self::Node) {
1275 node.state = self.state.clone();
1277 node.style = self.style.clone();
1278 node.cursor_brush = Brush::solid(self.cursor_color);
1279 node.line_limits = self.line_limits;
1280 node.handle_controller = self.handle_controller.clone();
1281
1282 node.cached_handler = TextFieldModifierNode::create_handler(
1284 node.state.clone(),
1285 node.refs.clone(),
1286 node.line_limits,
1287 self.style.clone(),
1288 );
1289
1290 node.cached_pan_resolver = TextFieldModifierNode::create_pan_resolver(
1292 node.state.clone(),
1293 node.refs.clone(),
1294 node.line_limits,
1295 self.style.clone(),
1296 );
1297
1298 if node.update_cached_state() {
1300 }
1303 }
1304
1305 fn capabilities(&self) -> NodeCapabilities {
1306 NodeCapabilities::LAYOUT
1307 | NodeCapabilities::DRAW
1308 | NodeCapabilities::SEMANTICS
1309 | NodeCapabilities::POINTER_INPUT
1310 }
1311
1312 fn always_update(&self) -> bool {
1313 true
1315 }
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321 use crate::text::TextStyle;
1322 use cranpose_core::{DefaultScheduler, Runtime};
1323 use std::sync::Arc;
1324
1325 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1327 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1328 f()
1329 }
1330
1331 #[test]
1332 fn text_field_node_creation() {
1333 let _app_context = crate::render_state::app_context_test_scope();
1334 with_test_runtime(|| {
1335 let state = TextFieldState::new("Hello");
1336 let node = TextFieldModifierNode::new(state, TextStyle::default());
1337 assert_eq!(node.text(), "Hello");
1338 assert!(!node.is_focused());
1339 });
1340 }
1341
1342 #[test]
1343 fn text_field_node_focus() {
1344 let _app_context = crate::render_state::app_context_test_scope();
1345 with_test_runtime(|| {
1346 let state = TextFieldState::new("Test");
1347 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1348 assert!(!node.is_focused());
1349
1350 node.set_focused(true);
1351 assert!(node.is_focused());
1352
1353 node.set_focused(false);
1354 assert!(!node.is_focused());
1355 });
1356 }
1357
1358 #[test]
1359 fn text_field_element_creates_node() {
1360 let _app_context = crate::render_state::app_context_test_scope();
1361 with_test_runtime(|| {
1362 let state = TextFieldState::new("Hello World");
1363 let element = TextFieldElement::new(state, TextStyle::default());
1364
1365 let node = element.create();
1366 assert_eq!(node.text(), "Hello World");
1367 });
1368 }
1369
1370 #[test]
1379 fn touch_press_publishes_touch_handle_metrics_but_mouse_does_not() {
1380 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1381 use cranpose_ui_graphics::Point;
1382
1383 let _app_context = crate::render_state::app_context_test_scope();
1384 with_test_runtime(|| {
1385 let state = TextFieldState::new("hello world");
1386 let controller = TextFieldHandleController::new();
1387 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1388 .with_handle_controller(controller.clone());
1389 node.measured_size.set(Size {
1391 width: 120.0,
1392 height: 20.0,
1393 });
1394
1395 let handler = node
1396 .pointer_input_handler()
1397 .expect("field exposes a pointer handler");
1398 let draw = node
1399 .create_draw_closure()
1400 .expect("field exposes a draw closure");
1401 let at = Point { x: 12.0, y: 8.0 };
1402 let size = Size {
1403 width: 120.0,
1404 height: 20.0,
1405 };
1406
1407 handler(
1410 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1411 );
1412 let _ = draw(size);
1413 let metrics = controller
1414 .metrics()
1415 .expect("focused field publishes handle metrics");
1416 assert!(metrics.focused, "a tap focuses the field");
1417 assert!(
1418 metrics.touch,
1419 "a touch tap must publish touch = true so the finger handles show"
1420 );
1421
1422 handler(
1425 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1426 );
1427 let _ = draw(size);
1428 let metrics = controller
1429 .metrics()
1430 .expect("focused field publishes handle metrics");
1431 assert!(
1432 !metrics.touch,
1433 "a mouse tap must publish touch = false (clean caret, no finger handle)"
1434 );
1435
1436 crate::text_field_focus::clear_focus();
1437 });
1438 }
1439
1440 #[test]
1448 fn double_tap_selects_the_word_under_the_finger() {
1449 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1450 use cranpose_ui_graphics::Point;
1451
1452 let _app_context = crate::render_state::app_context_test_scope();
1453 with_test_runtime(|| {
1454 let state = TextFieldState::new("hello world");
1455 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1456 node.measured_size.set(Size {
1457 width: 200.0,
1458 height: 20.0,
1459 });
1460 let handler = node
1461 .pointer_input_handler()
1462 .expect("field exposes a pointer handler");
1463
1464 let at = Point { x: 2.0, y: 8.0 };
1467 handler(
1468 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1469 );
1470 handler(
1471 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1472 );
1473
1474 let selection = state.selection();
1475 assert!(
1476 !selection.collapsed(),
1477 "a double tap must produce a (word) selection, got {selection:?}"
1478 );
1479 let selected = &state.text()[selection.min()..selection.max()];
1480 assert_eq!(
1481 selected, "hello",
1482 "double tap should select the whole word under the finger"
1483 );
1484
1485 crate::text_field_focus::clear_focus();
1486 });
1487 }
1488
1489 #[test]
1493 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1494 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1495 use cranpose_ui_graphics::Point;
1496
1497 let _app_context = crate::render_state::app_context_test_scope();
1498 with_test_runtime(|| {
1499 let text = "alpha beta\ngamma delta\n\nsecond para";
1502 let state = TextFieldState::new(text);
1503 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default())
1504 .with_line_limits(TextFieldLineLimits::MultiLine {
1505 min_lines: 1,
1506 max_lines: usize::MAX,
1507 });
1508 node.measured_size.set(Size {
1509 width: 400.0,
1510 height: 80.0,
1511 });
1512 let handler = node
1513 .pointer_input_handler()
1514 .expect("field exposes a pointer handler");
1515
1516 let at = Point { x: 2.0, y: 4.0 };
1518 let tap = || {
1519 handler(
1520 PointerEvent::new(PointerEventKind::Down, at, at)
1521 .with_source(PointerSource::Touch),
1522 );
1523 };
1524 let selected = |state: &TextFieldState| {
1525 let s = state.selection();
1526 state.text()[s.min()..s.max()].to_string()
1527 };
1528
1529 tap(); assert!(state.selection().collapsed(), "first tap places the caret");
1531 tap(); assert_eq!(selected(&state), "alpha", "double tap selects the word");
1533 tap(); assert_eq!(
1535 selected(&state),
1536 "alpha beta",
1537 "triple tap selects the line"
1538 );
1539 tap(); assert_eq!(
1541 selected(&state),
1542 "alpha beta\ngamma delta",
1543 "fourth tap grows to the paragraph"
1544 );
1545 tap(); assert_eq!(
1547 selected(&state),
1548 "alpha",
1549 "fifth tap cycles back to the word"
1550 );
1551
1552 crate::text_field_focus::clear_focus();
1553 });
1554 }
1555
1556 #[test]
1560 fn single_tap_inside_selection_selects_the_word() {
1561 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1562 use cranpose_ui_graphics::Point;
1563
1564 let _app_context = crate::render_state::app_context_test_scope();
1565 with_test_runtime(|| {
1566 let state = TextFieldState::new("hello world");
1567 let node = TextFieldModifierNode::new(state.clone(), TextStyle::default());
1568 node.measured_size.set(Size {
1569 width: 200.0,
1570 height: 20.0,
1571 });
1572 let handler = node
1573 .pointer_input_handler()
1574 .expect("field exposes a pointer handler");
1575
1576 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1578 assert!(!state.selection().collapsed());
1579
1580 let at = Point { x: 2.0, y: 8.0 };
1583 handler(
1584 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1585 );
1586
1587 let selection = state.selection();
1588 assert!(
1589 !selection.collapsed(),
1590 "a tap inside a selection must not collapse it, got {selection:?}"
1591 );
1592 assert_eq!(
1593 &state.text()[selection.min()..selection.max()],
1594 "hello",
1595 "a tap inside a selection re-selects the word under the finger"
1596 );
1597
1598 crate::text_field_focus::clear_focus();
1599 });
1600 }
1601
1602 #[test]
1603 fn text_field_element_equality() {
1604 let _app_context = crate::render_state::app_context_test_scope();
1605 with_test_runtime(|| {
1606 let state1 = TextFieldState::new("Hello");
1607 let state2 = TextFieldState::new("Hello"); let elem1 = TextFieldElement::new(state1.clone(), TextStyle::default());
1610 let elem2 = TextFieldElement::new(state1.clone(), TextStyle::default()); let elem3 = TextFieldElement::new(state2, TextStyle::default()); assert_eq!(elem1, elem2, "Same state should be equal");
1616 assert_ne!(elem1, elem3, "Different states should not be equal");
1617 });
1618 }
1619
1620 #[test]
1621 fn text_field_element_update_refreshes_existing_node_style() {
1622 let _app_context = crate::render_state::app_context_test_scope();
1623 with_test_runtime(|| {
1624 let state = TextFieldState::new("themed text");
1625 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1626 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1627 ..crate::text::SpanStyle::default()
1628 });
1629 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1630 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1631 ..crate::text::SpanStyle::default()
1632 });
1633 let initial = TextFieldElement::new(state.clone(), dark_style);
1634 let updated = TextFieldElement::new(state, light_style.clone());
1635 let mut node = initial.create();
1636
1637 updated.update(&mut node);
1638
1639 assert_eq!(node.text(), "themed text");
1640 assert_eq!(node.style(), &light_style);
1641 });
1642 }
1643
1644 #[test]
1649 fn multiline_field_measures_wrapped_height() {
1650 let _app_context = crate::render_state::app_context_test_scope();
1651 with_test_runtime(|| {
1652 let long = "abcd ".repeat(40); let state = TextFieldState::new(&long);
1654 let node = TextFieldModifierNode::new(state, TextStyle::default());
1655 assert!(
1656 !node.line_limits().is_single_line(),
1657 "default fields are multi-line"
1658 );
1659
1660 let natural = node.measure_text_content(None);
1661 let wrapped = node.measure_text_content(node.wrap_width(20.0));
1662
1663 assert!(
1664 wrapped.height > natural.height,
1665 "wrapped multi-line height {} must exceed the single-line height {}",
1666 wrapped.height,
1667 natural.height
1668 );
1669 });
1670 }
1671
1672 #[test]
1675 fn single_line_field_never_wraps() {
1676 let _app_context = crate::render_state::app_context_test_scope();
1677 with_test_runtime(|| {
1678 let state = TextFieldState::new("abcd ".repeat(40));
1679 let node = TextFieldModifierNode::new(state, TextStyle::default())
1680 .with_line_limits(TextFieldLineLimits::SingleLine);
1681 assert_eq!(
1682 node.wrap_width(20.0),
1683 None,
1684 "single-line fields must not wrap"
1685 );
1686 });
1687 }
1688
1689 #[test]
1695 fn test_cursor_x_position_calculation() {
1696 let _app_context = crate::render_state::app_context_test_scope();
1697 with_test_runtime(|| {
1698 let style = crate::text::TextStyle::default();
1700
1701 let empty_width =
1703 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1704 assert!(
1705 empty_width.abs() < 0.1,
1706 "Empty text should have 0 width, got {}",
1707 empty_width
1708 );
1709
1710 let hi_width =
1712 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1713 assert!(
1714 hi_width > 0.0,
1715 "Text 'Hi' should have positive width: {}",
1716 hi_width
1717 );
1718
1719 let h_width =
1721 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1722 assert!(h_width > 0.0, "Text 'H' should have positive width");
1723 assert!(
1724 h_width < hi_width,
1725 "'H' width {} should be less than 'Hi' width {}",
1726 h_width,
1727 hi_width
1728 );
1729
1730 let state = TextFieldState::new("Hi");
1732 assert_eq!(
1733 state.selection().start,
1734 2,
1735 "Cursor should be at position 2 (end of 'Hi')"
1736 );
1737
1738 let text = state.text();
1740 let cursor_pos = state.selection().start;
1741 let text_before_cursor = &text[..cursor_pos.min(text.len())];
1742 assert_eq!(text_before_cursor, "Hi");
1743
1744 let cursor_x = crate::text::measure_text(
1746 &crate::text::AnnotatedString::from(text_before_cursor),
1747 &style,
1748 )
1749 .width;
1750 assert!(
1751 (cursor_x - hi_width).abs() < 0.1,
1752 "Cursor x {} should equal 'Hi' width {}",
1753 cursor_x,
1754 hi_width
1755 );
1756 });
1757 }
1758
1759 #[test]
1761 fn test_focused_node_creates_cursor() {
1762 let _app_context = crate::render_state::app_context_test_scope();
1763 with_test_runtime(|| {
1764 let state = TextFieldState::new("Test");
1765 let element = TextFieldElement::new(state.clone(), TextStyle::default());
1766 let node = element.create();
1767
1768 assert!(!node.is_focused());
1770
1771 *node.refs.is_focused.borrow_mut() = true;
1773 assert!(node.is_focused());
1774
1775 assert_eq!(node.text(), "Test");
1777
1778 assert_eq!(node.selection().start, 4);
1780 });
1781 }
1782}