1use std::{
2 cell::{Cell, RefCell},
3 hash::{Hash, Hasher},
4 rc::Rc,
5};
6
7use cranpose_core::{MutableState, mutableStateOf};
8use cranpose_foundation::{
9 Constraints, DelegatableNode, DrawModifierNode, DrawScope, InvalidationKind,
10 LayoutModifierNode, Measurable, ModifierNode, ModifierNodeContext, ModifierNodeElement,
11 NodeCapabilities, NodeState, PointerEvent, PointerEventKind, PointerInputNode,
12 SemanticsConfiguration, SemanticsNode, Size,
13 text::{TextFieldLineLimits, TextFieldState, TextRange},
14};
15use cranpose_ui_graphics::{Brush, Color, Point};
16
17#[derive(Clone, Copy, PartialEq, Debug)]
18pub struct TextFieldHandleMetrics {
19 pub focused: bool,
20 pub direct_manipulation: bool,
21 pub node_origin: Point,
22 pub padding_left: f32,
23 pub padding_top: f32,
24 pub scroll_offset: f32,
25 pub line_height: f32,
26 pub glyph_box: (f32, f32),
27 pub wrap_width: Option<f32>,
28}
29
30#[derive(Clone)]
31pub struct TextFieldHandleController {
32 inner: Rc<TextFieldHandleControllerInner>,
33}
34
35impl PartialEq for TextFieldHandleController {
36 fn eq(&self, other: &Self) -> bool {
37 Rc::ptr_eq(&self.inner, &other.inner)
38 }
39}
40
41struct TextFieldHandleControllerInner {
42 metrics: Cell<Option<TextFieldHandleMetrics>>,
43 revision: MutableState<u64>,
44 gesture_claim: RefCell<Option<Rc<Cell<bool>>>>,
45 press_track: Cell<Option<MutableState<Option<PointerPressTrack>>>>,
46}
47
48impl TextFieldHandleController {
49 pub fn new() -> Self {
50 Self {
51 inner: Rc::new(TextFieldHandleControllerInner {
52 metrics: Cell::new(None),
53 revision: mutableStateOf(0u64),
54 gesture_claim: RefCell::new(None),
55 press_track: Cell::new(None),
56 }),
57 }
58 }
59
60 pub(crate) fn publish(&self, metrics: TextFieldHandleMetrics) {
61 if self.inner.metrics.get() != Some(metrics) {
62 self.inner.metrics.set(Some(metrics));
63 self.inner
64 .revision
65 .update(|value| *value = value.wrapping_add(1));
66 }
67 }
68
69 pub fn metrics(&self) -> Option<TextFieldHandleMetrics> {
70 let _ = self.inner.revision.value();
71 self.inner.metrics.get()
72 }
73
74 pub(crate) fn adopt_gesture_claim(&self, claim: &Rc<Cell<bool>>) {
75 let mut slot = self.inner.gesture_claim.borrow_mut();
76 let adopted = slot.as_ref().is_some_and(|held| Rc::ptr_eq(held, claim));
77 if !adopted {
78 *slot = Some(Rc::clone(claim));
79 }
80 }
81
82 pub(crate) fn adopt_press_track(&self, press_track: MutableState<Option<PointerPressTrack>>) {
83 if self.inner.press_track.get() != Some(press_track) {
84 self.inner.press_track.set(Some(press_track));
85 self.inner
86 .revision
87 .update(|value| *value = value.wrapping_add(1));
88 }
89 }
90
91 pub fn press(&self) -> Option<PointerPressTrack> {
92 self.inner.press_track.get().and_then(|state| state.get())
93 }
94
95 pub fn claim_gesture(&self) {
96 if let Some(claim) = self.inner.gesture_claim.borrow().as_ref() {
97 claim.set(true);
98 }
99 }
100
101 pub fn gesture_claimed(&self) -> bool {
102 self.inner
103 .gesture_claim
104 .borrow()
105 .as_ref()
106 .is_some_and(|claim| claim.get())
107 }
108}
109
110impl Default for TextFieldHandleController {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116const DEFAULT_CURSOR_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0);
117
118const DEFAULT_SELECTION_COLOR: Color = Color(0.0, 0.5, 1.0, 0.3);
119
120const DEFAULT_LINE_HEIGHT: f32 = 20.0;
121
122const CURSOR_WIDTH: f32 = 2.0;
123
124pub(crate) fn compute_horizontal_scroll_offset(
125 current_offset: f32,
126 cursor_x: f32,
127 text_width: f32,
128 viewport_width: f32,
129) -> f32 {
130 if viewport_width <= 0.0 {
131 return 0.0;
132 }
133 let max_offset = (text_width + CURSOR_WIDTH - viewport_width).max(0.0);
134 let mut offset = current_offset.clamp(0.0, max_offset);
135 let visible_end = offset + viewport_width - CURSOR_WIDTH;
136 if cursor_x > visible_end {
137 offset = cursor_x - viewport_width + CURSOR_WIDTH;
138 } else if cursor_x < offset {
139 offset = cursor_x;
140 }
141 offset.clamp(0.0, max_offset)
142}
143
144pub(crate) fn intersect_rect(
145 rect: cranpose_ui_graphics::Rect,
146 bounds: cranpose_ui_graphics::Rect,
147) -> Option<cranpose_ui_graphics::Rect> {
148 let x0 = rect.x.max(bounds.x);
149 let y0 = rect.y.max(bounds.y);
150 let x1 = (rect.x + rect.width).min(bounds.x + bounds.width);
151 let y1 = (rect.y + rect.height).min(bounds.y + bounds.height);
152 (x1 > x0 && y1 > y0).then_some(cranpose_ui_graphics::Rect {
153 x: x0,
154 y: y0,
155 width: x1 - x0,
156 height: y1 - y0,
157 })
158}
159
160pub type TextPanResolver = Rc<dyn Fn(f32) -> f32>;
163
164pub(crate) fn caret_visual_line_for_offset(
165 text: &str,
166 style: &TextStyle,
167 node_id: Option<cranpose_core::NodeId>,
168 wrap_width: Option<f32>,
169 offset: usize,
170 affinity: crate::text_selection::LineAffinity,
171) -> (usize, usize) {
172 let offset = offset.min(text.len());
173 match wrap_width {
174 Some(width) if width.is_finite() && width > 0.0 => {
175 let annotated = crate::text::AnnotatedString::from(text);
176 let ranges = crate::text::wrapped_line_ranges(
177 node_id,
178 &annotated,
179 style,
180 crate::text::TextLayoutOptions::default(),
181 Some(width),
182 );
183 crate::text_selection::caret_visual_line(&ranges, offset, affinity)
184 }
185 _ => {
186 let before = &text[..offset];
187 let line_index = before.matches('\n').count();
188 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
189 (line_index, line_start)
190 }
191 }
192}
193
194#[allow(clippy::too_many_arguments)]
195pub(crate) fn range_visual_line_rects(
196 text: &str,
197 style: &TextStyle,
198 node_id: Option<cranpose_core::NodeId>,
199 wrap_width: Option<f32>,
200 padding_left: f32,
201 padding_top: f32,
202 pan: f32,
203 line_height: f32,
204 start: usize,
205 end: usize,
206) -> Vec<cranpose_ui_graphics::Rect> {
207 if start >= end {
208 return Vec::new();
209 }
210 let annotated = crate::text::AnnotatedString::from(text);
211 let line_ranges = crate::text::wrapped_line_ranges(
212 node_id,
213 &annotated,
214 style,
215 crate::text::TextLayoutOptions::default(),
216 wrap_width,
217 );
218 let mut rects = Vec::new();
219 for (line_idx, line_range) in line_ranges.iter().enumerate() {
220 let line_start = line_range.start;
221 let line_end = line_range.end;
222 if end <= line_start || start >= line_end {
223 continue;
224 }
225 let seg_start = start.max(line_start);
226 let seg_end = end.min(line_end);
227 let x0 = crate::text::measure_text(
228 &crate::text::AnnotatedString::from(&text[line_start..seg_start]),
229 style,
230 )
231 .width
232 + padding_left
233 - pan;
234 let x1 = crate::text::measure_text(
235 &crate::text::AnnotatedString::from(&text[line_start..seg_end]),
236 style,
237 )
238 .width
239 + padding_left
240 - pan;
241 let width = x1 - x0;
242 if width > 0.0 {
243 rects.push(cranpose_ui_graphics::Rect {
244 x: x0,
245 y: padding_top + line_idx as f32 * line_height,
246 width,
247 height: line_height,
248 });
249 }
250 }
251 rects
252}
253
254#[derive(Clone)]
255pub(crate) struct TextFieldRefs {
256 pub is_focused: Rc<RefCell<bool>>,
257 pub content_offset: Rc<Cell<f32>>,
258 pub content_y_offset: Rc<Cell<f32>>,
259 pub drag_anchor: Rc<Cell<Option<usize>>>,
260 pub last_click_time: Rc<Cell<Option<web_time::Instant>>>,
261 pub last_click_pos: Rc<Cell<Option<(f32, f32)>>>,
262 pub click_count: Rc<Cell<u8>>,
263 pub node_id: Rc<Cell<Option<cranpose_core::NodeId>>>,
264 pub scroll_offset: Rc<Cell<f32>>,
265 pub direct_manipulation: Rc<Cell<bool>>,
266 pub node_origin: Rc<Cell<Point>>,
267 pub line_height: Rc<Cell<f32>>,
268 pub wrap_width: Rc<Cell<Option<f32>>>,
269 pub press_track: MutableState<Option<PointerPressTrack>>,
270 pub gesture_claimed: Rc<Cell<bool>>,
271}
272
273#[derive(Clone, Copy, Debug, PartialEq)]
274pub struct PointerPressTrack {
275 pub start: Point,
276 pub position: Point,
277}
278
279impl TextFieldRefs {
280 pub fn new() -> Self {
281 Self {
282 is_focused: Rc::new(RefCell::new(false)),
283 content_offset: Rc::new(Cell::new(0.0_f32)),
284 content_y_offset: Rc::new(Cell::new(0.0_f32)),
285 drag_anchor: Rc::new(Cell::new(None::<usize>)),
286 last_click_time: Rc::new(Cell::new(None::<web_time::Instant>)),
287 last_click_pos: Rc::new(Cell::new(None::<(f32, f32)>)),
288 click_count: Rc::new(Cell::new(0_u8)),
289 node_id: Rc::new(Cell::new(None::<cranpose_core::NodeId>)),
290 scroll_offset: Rc::new(Cell::new(0.0_f32)),
291 direct_manipulation: Rc::new(Cell::new(false)),
292 node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
293 line_height: Rc::new(Cell::new(DEFAULT_LINE_HEIGHT)),
294 wrap_width: Rc::new(Cell::new(None::<f32>)),
295 press_track: mutableStateOf(None::<PointerPressTrack>),
296 gesture_claimed: Rc::new(Cell::new(false)),
297 }
298 }
299}
300
301use crate::text::TextStyle;
302
303pub struct TextFieldModifierNode {
304 state: TextFieldState,
305 refs: TextFieldRefs,
306 style: TextStyle,
307 cursor_brush: Brush,
308 selection_brush: Brush,
309 line_limits: TextFieldLineLimits,
310 cached_text: String,
311 cached_selection: TextRange,
312 node_state: NodeState,
313 measured_size: Rc<Cell<Size>>,
314 measured_line_height: Rc<Cell<f32>>,
315 measured_wrap_width: Rc<Cell<Option<f32>>>,
316 cached_handler: Rc<dyn Fn(PointerEvent)>,
317 cached_pan_resolver: TextPanResolver,
318 handle_controller: Option<TextFieldHandleController>,
319 modal_depth: usize,
320}
321
322impl std::fmt::Debug for TextFieldModifierNode {
323 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324 f.debug_struct("TextFieldModifierNode")
325 .field("text", &self.state.text())
326 .field("style", &self.style)
327 .field("is_focused", &*self.refs.is_focused.borrow())
328 .finish()
329 }
330}
331
332use crate::text_field_handler::TextFieldHandler;
333
334impl TextFieldModifierNode {
335 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
337 let value = state.value();
338 let refs = TextFieldRefs::new();
339 let refs_line_height = refs.line_height.clone();
340 let refs_wrap_width = refs.wrap_width.clone();
341 let line_limits = TextFieldLineLimits::default();
342 let cached_handler =
343 Self::create_handler(state, refs.clone(), line_limits, style.clone(), 0);
344 let cached_pan_resolver =
345 Self::create_pan_resolver(state, refs.clone(), line_limits, style.clone());
346
347 Self {
348 state,
349 refs,
350 style,
351 cursor_brush: Brush::solid(DEFAULT_CURSOR_COLOR),
352 selection_brush: Brush::solid(DEFAULT_SELECTION_COLOR),
353 line_limits,
354 cached_text: value.text,
355 cached_selection: value.selection,
356 node_state: NodeState::new(),
357 measured_size: Rc::new(Cell::new(Size {
358 width: 0.0,
359 height: 0.0,
360 })),
361 measured_line_height: refs_line_height,
362 measured_wrap_width: refs_wrap_width,
363 cached_handler,
364 cached_pan_resolver,
365 handle_controller: None,
366 modal_depth: 0,
367 }
368 }
369
370 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
372 self.line_limits = line_limits;
373 self.rebuild_cached_closures();
374 self
375 }
376
377 fn rebuild_cached_closures(&mut self) {
378 self.cached_handler = Self::create_handler(
379 self.state,
380 self.refs.clone(),
381 self.line_limits,
382 self.style.clone(),
383 self.modal_depth,
384 );
385 self.cached_pan_resolver = Self::create_pan_resolver(
386 self.state,
387 self.refs.clone(),
388 self.line_limits,
389 self.style.clone(),
390 );
391 }
392
393 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
395 self.handle_controller = Some(controller);
396 self
397 }
398
399 fn create_pan_resolver(
400 state: TextFieldState,
401 refs: TextFieldRefs,
402 line_limits: TextFieldLineLimits,
403 style: TextStyle,
404 ) -> TextPanResolver {
405 Rc::new(move |viewport_width: f32| {
406 if !line_limits.is_single_line() {
407 refs.scroll_offset.set(0.0);
408 return 0.0;
409 }
410 let text = state.text();
411 let pos = state.selection().start.min(text.len());
412 let text_width = crate::text::measure_text(
413 &crate::text::AnnotatedString::from(text.as_str()),
414 &style,
415 )
416 .width;
417 let cursor_x = crate::text::measure_text(
418 &crate::text::AnnotatedString::from(&text[..pos]),
419 &style,
420 )
421 .width;
422 let offset = compute_horizontal_scroll_offset(
423 refs.scroll_offset.get(),
424 cursor_x,
425 text_width,
426 viewport_width,
427 );
428 refs.scroll_offset.set(offset);
429 offset
430 })
431 }
432
433 pub fn text_pan_resolver(&self) -> Option<TextPanResolver> {
438 self.line_limits
439 .is_single_line()
440 .then(|| self.cached_pan_resolver.clone())
441 }
442
443 pub fn scroll_offset(&self) -> f32 {
445 self.refs.scroll_offset.get()
446 }
447
448 pub fn line_limits(&self) -> TextFieldLineLimits {
450 self.line_limits
451 }
452
453 fn create_handler(
454 state: TextFieldState,
455 refs: TextFieldRefs,
456 line_limits: TextFieldLineLimits,
457 style: TextStyle,
458 modal_depth: usize,
459 ) -> Rc<dyn Fn(PointerEvent)> {
460 use crate::{
461 text_selection::{
462 MULTI_TAP_SLOP_PX, MULTI_TAP_TIMEOUT_MS, SelectionGranularity, classify_tap_count,
463 find_line_boundaries, find_paragraph_boundaries, resolve_selection_tap_count,
464 tap_selection_granularity,
465 },
466 word_boundaries::find_word_boundaries,
467 };
468
469 Rc::new(move |event: PointerEvent| {
470 refs.node_origin.set(Point {
471 x: event.global_position.x - event.position.x,
472 y: event.global_position.y - event.position.y,
473 });
474
475 let click_x =
476 (event.position.x - refs.content_offset.get() + refs.scroll_offset.get()).max(0.0);
477 let click_y = (event.position.y - refs.content_y_offset.get()).max(0.0);
478
479 match event.kind {
480 PointerEventKind::Down => {
481 refs.direct_manipulation.set(true);
482 refs.press_track.set(Some(PointerPressTrack {
483 start: event.global_position,
484 position: event.global_position,
485 }));
486 refs.gesture_claimed.set(false);
487
488 let handler = TextFieldHandler::new(
489 state,
490 refs.node_id.get(),
491 line_limits,
492 crate::text_field_handler::CaretGeometryRefs {
493 node_origin: refs.node_origin.clone(),
494 content_offset: refs.content_offset.clone(),
495 content_y_offset: refs.content_y_offset.clone(),
496 scroll_offset: refs.scroll_offset.clone(),
497 style: style.clone(),
498 },
499 );
500 crate::text_field_focus::request_focus(
501 refs.is_focused.clone(),
502 handler,
503 modal_depth,
504 );
505
506 let now = web_time::Instant::now();
507 let text = state.text();
508 let pos = crate::text::offset_for_position_wrapped(
509 &text,
510 &style,
511 refs.node_id.get(),
512 refs.wrap_width.get(),
513 refs.line_height.get(),
514 click_x,
515 click_y,
516 );
517
518 let previous = refs.last_click_pos.get().and_then(|(px, py)| {
519 let count = refs.click_count.get();
520 (count > 0).then_some((count, px, py))
521 });
522 let elapsed_ms = refs
523 .last_click_time
524 .get()
525 .map(|last| now.duration_since(last).as_millis())
526 .unwrap_or(u128::MAX);
527 let tap_count = classify_tap_count(
528 previous,
529 elapsed_ms,
530 event.position.x,
531 event.position.y,
532 MULTI_TAP_TIMEOUT_MS,
533 MULTI_TAP_SLOP_PX,
534 );
535
536 let selection = state.selection();
537 let tap_in_selection =
538 !selection.collapsed() && pos >= selection.min() && pos <= selection.max();
539 let repeat_in_place = refs
540 .last_click_pos
541 .get()
542 .map(|(px, py)| {
543 let dx = event.position.x - px;
544 let dy = event.position.y - py;
545 dx * dx + dy * dy <= MULTI_TAP_SLOP_PX * MULTI_TAP_SLOP_PX
546 })
547 .unwrap_or(false);
548 let effective_count = resolve_selection_tap_count(
549 tap_count,
550 refs.click_count.get(),
551 tap_in_selection,
552 repeat_in_place,
553 );
554
555 match tap_selection_granularity(effective_count) {
556 SelectionGranularity::Paragraph => {
557 let (start, end) = find_paragraph_boundaries(&text, pos);
558 state.edit(|buffer| {
559 buffer.select(TextRange::new(start, end));
560 });
561 refs.drag_anchor.set(Some(start));
562 }
563 SelectionGranularity::Line => {
564 let (line_start, line_end) = find_line_boundaries(&text, pos);
565 state.edit(|buffer| {
566 buffer.select(TextRange::new(line_start, line_end));
567 });
568 refs.drag_anchor.set(Some(line_start));
569 }
570 SelectionGranularity::Word => {
571 let (word_start, word_end) = find_word_boundaries(&text, pos);
572 state.edit(|buffer| {
573 buffer.select(TextRange::new(word_start, word_end));
574 });
575 refs.drag_anchor.set(Some(word_start));
576 }
577 SelectionGranularity::Caret => {
578 refs.drag_anchor.set(Some(pos));
579 state.edit(|buffer| {
580 buffer.place_cursor_before_char(pos);
581 });
582 }
583 }
584
585 refs.click_count.set(effective_count);
586 refs.last_click_time.set(Some(now));
587 refs.last_click_pos
588 .set(Some((event.position.x, event.position.y)));
589 event.consume();
590 }
591 PointerEventKind::Move => {
592 if let Some(mut track) = refs.press_track.get() {
593 track.position = event.global_position;
594 refs.press_track.set(Some(track));
595 if let Some(node_id) = refs.node_id.get() {
596 crate::schedule_draw_repass(node_id);
597 }
598 crate::request_render_invalidation();
599 }
600 if refs.gesture_claimed.get() {
601 event.consume();
602 return;
603 }
604 if let Some(anchor) = refs.drag_anchor.get()
605 && *refs.is_focused.borrow()
606 {
607 let text = state.text();
608 let current_pos = crate::text::offset_for_position_wrapped(
609 &text,
610 &style,
611 refs.node_id.get(),
612 refs.wrap_width.get(),
613 refs.line_height.get(),
614 click_x,
615 click_y,
616 );
617
618 state.set_selection(TextRange::new(anchor, current_pos));
619
620 crate::request_render_invalidation();
621
622 event.consume();
623 }
624 }
625 PointerEventKind::Up => {
626 refs.drag_anchor.set(None);
627 refs.press_track.set(None);
628 refs.gesture_claimed.set(false);
629 if let Some(node_id) = refs.node_id.get() {
630 crate::schedule_draw_repass(node_id);
631 }
632 crate::request_render_invalidation();
633 }
634 PointerEventKind::Cancel => {
635 refs.press_track.set(None);
636 refs.gesture_claimed.set(false);
637 if let Some(node_id) = refs.node_id.get() {
638 crate::schedule_draw_repass(node_id);
639 }
640 crate::request_render_invalidation();
641 }
642 _ => {}
643 }
644 })
645 }
646
647 pub fn with_cursor_color(mut self, color: Color) -> Self {
652 self.cursor_brush = Brush::solid(color);
653 self.selection_brush = Brush::solid(
654 color.with_alpha(crate::widgets::basic_text_field::SELECTION_HIGHLIGHT_ALPHA),
655 );
656 self
657 }
658
659 pub fn set_focused(&mut self, focused: bool) {
661 let current = *self.refs.is_focused.borrow();
662 if current != focused {
663 *self.refs.is_focused.borrow_mut() = focused;
664 if !focused {
665 self.refs.direct_manipulation.set(false);
666 self.refs.press_track.set(None);
667 self.refs.gesture_claimed.set(false);
668 }
669 }
670 }
671
672 pub fn is_focused(&self) -> bool {
674 *self.refs.is_focused.borrow()
675 }
676
677 pub(crate) fn window_origin_sink(&self) -> Rc<Cell<Point>> {
678 self.refs.node_origin.clone()
679 }
680
681 pub fn text(&self) -> String {
683 self.state.text()
684 }
685
686 pub fn style(&self) -> &TextStyle {
687 &self.style
688 }
689
690 pub fn selection(&self) -> TextRange {
692 self.state.selection()
693 }
694
695 pub fn cursor_brush(&self) -> Brush {
697 self.cursor_brush.clone()
698 }
699
700 pub fn selection_brush(&self) -> Brush {
702 self.selection_brush.clone()
703 }
704
705 pub fn insert_text(&mut self, text: &str) {
707 self.state.edit(|buffer| {
708 buffer.insert(text);
709 });
710 }
711
712 pub fn copy_selection(&self) -> Option<String> {
715 self.state.copy_selection()
716 }
717
718 pub fn cut_selection(&mut self) -> Option<String> {
721 let text = self.copy_selection();
722 if text.is_some() {
723 self.state.edit(|buffer| {
724 buffer.delete(buffer.selection());
725 });
726 }
727 text
728 }
729
730 pub fn set_content_offset(&self, offset: f32) {
733 self.refs.content_offset.set(offset);
734 }
735
736 pub fn set_content_y_offset(&self, offset: f32) {
739 self.refs.content_y_offset.set(offset);
740 }
741
742 fn wrap_width(&self, available_width: f32) -> Option<f32> {
743 (!self.line_limits.is_single_line() && available_width.is_finite() && available_width > 0.0)
744 .then_some(available_width)
745 }
746
747 fn measure_text_content(&self, wrap_width: Option<f32>) -> Size {
748 let text = self.state.text();
749 let node_id = self.refs.node_id.get();
750 let annotated = crate::text::AnnotatedString::from(text.as_str());
751 let metrics = match wrap_width {
752 Some(max_width) => crate::text::measure_text_with_options_for_node(
753 node_id,
754 &annotated,
755 &self.style,
756 crate::text::TextLayoutOptions::default(),
757 Some(max_width),
758 ),
759 None => crate::text::measure_text_for_node(node_id, &annotated, &self.style),
760 };
761 self.measured_line_height.set(metrics.line_height);
762 Size {
763 width: metrics.width,
764 height: metrics.height,
765 }
766 }
767
768 fn update_cached_state(&mut self) -> bool {
769 let value = self.state.value();
770 let text_changed = value.text != self.cached_text;
771 let selection_changed = value.selection != self.cached_selection;
772
773 if text_changed {
774 self.cached_text = value.text;
775 }
776 if selection_changed {
777 self.cached_selection = value.selection;
778 }
779
780 text_changed || selection_changed
781 }
782}
783
784impl DelegatableNode for TextFieldModifierNode {
785 fn node_state(&self) -> &NodeState {
786 &self.node_state
787 }
788}
789
790impl ModifierNode for TextFieldModifierNode {
791 fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
792 self.refs.node_id.set(context.node_id());
793
794 context.invalidate(InvalidationKind::Layout);
795 context.invalidate(InvalidationKind::Draw);
796 context.invalidate(InvalidationKind::Semantics);
797 }
798
799 fn as_draw_node(&self) -> Option<&dyn DrawModifierNode> {
800 Some(self)
801 }
802
803 fn as_draw_node_mut(&mut self) -> Option<&mut dyn DrawModifierNode> {
804 Some(self)
805 }
806
807 fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
808 Some(self)
809 }
810
811 fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
812 Some(self)
813 }
814
815 fn as_semantics_node(&self) -> Option<&dyn SemanticsNode> {
816 Some(self)
817 }
818
819 fn as_semantics_node_mut(&mut self) -> Option<&mut dyn SemanticsNode> {
820 Some(self)
821 }
822
823 fn as_pointer_input_node(&self) -> Option<&dyn PointerInputNode> {
824 Some(self)
825 }
826
827 fn as_pointer_input_node_mut(&mut self) -> Option<&mut dyn PointerInputNode> {
828 Some(self)
829 }
830}
831
832impl LayoutModifierNode for TextFieldModifierNode {
833 fn measure(
834 &self,
835 _context: &mut dyn ModifierNodeContext,
836 _measurable: &dyn Measurable,
837 constraints: Constraints,
838 ) -> cranpose_ui_layout::LayoutModifierMeasureResult {
839 let wrap_width = self.wrap_width(constraints.max_width);
840 self.measured_wrap_width.set(wrap_width);
841 let text_size = self.measure_text_content(wrap_width);
842
843 let min_height = if text_size.height < 1.0 {
844 DEFAULT_LINE_HEIGHT
845 } else {
846 text_size.height
847 };
848
849 let width = text_size
850 .width
851 .max(constraints.min_width)
852 .min(constraints.max_width);
853 let height = min_height
854 .max(constraints.min_height)
855 .min(constraints.max_height);
856
857 let size = Size { width, height };
858 self.measured_size.set(size);
859
860 let _ = (self.cached_pan_resolver)(size.width);
861
862 cranpose_ui_layout::LayoutModifierMeasureResult::with_size(size)
863 }
864
865 fn min_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
866 self.measure_text_content(None).width
867 }
868
869 fn max_intrinsic_width(&self, _measurable: &dyn Measurable, _height: f32) -> f32 {
870 self.measure_text_content(None).width
871 }
872
873 fn min_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
874 self.measure_text_content(self.wrap_width(width))
875 .height
876 .max(DEFAULT_LINE_HEIGHT)
877 }
878
879 fn max_intrinsic_height(&self, _measurable: &dyn Measurable, width: f32) -> f32 {
880 self.measure_text_content(self.wrap_width(width))
881 .height
882 .max(DEFAULT_LINE_HEIGHT)
883 }
884}
885
886fn content_viewport(
887 measured: cranpose_ui_graphics::Size,
888 size: cranpose_foundation::Size,
889 padding_left: f32,
890 padding_top: f32,
891) -> (f32, f32) {
892 let width = if measured.width > 0.0 {
893 measured.width
894 } else {
895 (size.width - padding_left).max(0.0)
896 };
897 let height = if measured.height > 0.0 {
898 measured.height
899 } else {
900 (size.height - padding_top).max(0.0)
901 };
902 (width, height)
903}
904
905impl DrawModifierNode for TextFieldModifierNode {
906 fn draw(&self, _draw_scope: &mut dyn DrawScope) {}
907
908 fn create_draw_closure(
909 &self,
910 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
911 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
912
913 let is_focused = self.refs.is_focused.clone();
914 let state = self.state;
915 let content_offset = self.refs.content_offset.clone();
916 let content_y_offset = self.refs.content_y_offset.clone();
917 let cursor_brush = self.cursor_brush.clone();
918 let style = self.style.clone();
919 let cached_line_height = self.measured_line_height.clone();
920 let measured_size = self.measured_size.clone();
921 let measured_wrap_width = self.measured_wrap_width.clone();
922 let node_id = self.refs.node_id.clone();
923 let pan_resolver = self.cached_pan_resolver.clone();
924 let handle_controller = self.handle_controller.clone();
925 let node_origin = self.refs.node_origin.clone();
926 let direct_manipulation = self.refs.direct_manipulation.clone();
927 let press_track = self.refs.press_track;
928 let gesture_claimed = self.refs.gesture_claimed.clone();
929
930 Some(Rc::new(move |scope| {
931 let size = scope.size();
932 if !*is_focused.borrow() {
933 if let Some(controller) = &handle_controller {
934 controller.publish(TextFieldHandleMetrics {
935 focused: false,
936 direct_manipulation: false,
937 node_origin: node_origin.get(),
938 padding_left: 0.0,
939 padding_top: 0.0,
940 scroll_offset: 0.0,
941 line_height: cached_line_height.get(),
942 glyph_box: crate::text::glyph_line_box(&style, cached_line_height.get()),
943 wrap_width: measured_wrap_width.get(),
944 });
945 }
946 return;
947 }
948
949 let mut primitives = Vec::new();
950
951 let text = state.text();
952 let selection = state.selection();
953 let padding_left = content_offset.get();
954 let padding_top = content_y_offset.get();
955 let line_height = cached_line_height.get();
956
957 let (viewport_width, viewport_height) =
958 content_viewport(measured_size.get(), size, padding_left, padding_top);
959 let pan = pan_resolver(viewport_width);
960
961 if let Some(controller) = &handle_controller {
962 controller.adopt_gesture_claim(&gesture_claimed);
963 controller.adopt_press_track(press_track);
964 controller.publish(TextFieldHandleMetrics {
965 focused: true,
966 direct_manipulation: direct_manipulation.get(),
967 node_origin: node_origin.get(),
968 padding_left,
969 padding_top,
970 scroll_offset: pan,
971 line_height,
972 glyph_box: crate::text::glyph_line_box(&style, line_height),
973 wrap_width: measured_wrap_width.get(),
974 });
975 }
976 let clip_bounds = cranpose_ui_graphics::Rect {
977 x: padding_left,
978 y: padding_top,
979 width: viewport_width,
980 height: viewport_height,
981 };
982
983 if let Some(comp_range) = state.composition() {
984 let comp_start = comp_range.min();
985 let comp_end = comp_range.max();
986
987 if comp_start < comp_end && comp_end <= text.len() {
988 let underline_brush = cranpose_ui_graphics::Brush::solid(
989 cranpose_ui_graphics::Color(0.8, 0.8, 0.8, 0.8),
990 );
991 let underline_height: f32 = 2.0;
992
993 for line_rect in range_visual_line_rects(
994 &text,
995 &style,
996 node_id.get(),
997 measured_wrap_width.get(),
998 padding_left,
999 padding_top,
1000 pan,
1001 line_height,
1002 comp_start,
1003 comp_end,
1004 ) {
1005 let underline_rect = cranpose_ui_graphics::Rect {
1006 x: line_rect.x,
1007 y: line_rect.y + line_height - underline_height,
1008 width: line_rect.width,
1009 height: underline_height,
1010 };
1011 if let Some(clipped) = intersect_rect(underline_rect, clip_bounds) {
1012 primitives.push(DrawPrimitive::Rect {
1013 rect: clipped,
1014 brush: underline_brush.clone(),
1015 stroke: None,
1016 });
1017 }
1018 }
1019 }
1020 }
1021
1022 if selection.collapsed() && crate::cursor_animation::is_cursor_visible() {
1023 let pos = selection.start.min(text.len());
1024 let (line_index, line_start) = caret_visual_line_for_offset(
1025 &text,
1026 &style,
1027 node_id.get(),
1028 measured_wrap_width.get(),
1029 pos,
1030 crate::text_selection::LineAffinity::Upstream,
1031 );
1032 let cursor_x = crate::text::measure_text(
1033 &crate::text::AnnotatedString::from(&text[line_start..pos]),
1034 &style,
1035 )
1036 .width
1037 + padding_left
1038 - pan;
1039 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1040 let cursor_y = padding_top + line_index as f32 * line_height + box_off;
1041
1042 let cursor_rect = cranpose_ui_graphics::Rect {
1043 x: cursor_x,
1044 y: cursor_y,
1045 width: CURSOR_WIDTH,
1046 height: box_h,
1047 };
1048
1049 if let Some(clipped) = intersect_rect(cursor_rect, clip_bounds) {
1050 primitives.push(DrawPrimitive::Rect {
1051 rect: clipped,
1052 brush: cursor_brush.clone(),
1053 stroke: None,
1054 });
1055 }
1056 }
1057
1058 scope.push_recorded(primitives);
1059 }))
1060 }
1061
1062 fn create_behind_draw_closure(
1063 &self,
1064 ) -> Option<Rc<dyn Fn(&mut cranpose_ui_graphics::DrawScopeDefault)>> {
1065 use cranpose_ui_graphics::{DrawPrimitive, DrawScope as _};
1066
1067 let is_focused = self.refs.is_focused.clone();
1068 let state = self.state;
1069 let content_offset = self.refs.content_offset.clone();
1070 let content_y_offset = self.refs.content_y_offset.clone();
1071 let selection_brush = self.selection_brush.clone();
1072 let style = self.style.clone();
1073 let cached_line_height = self.measured_line_height.clone();
1074 let measured_size = self.measured_size.clone();
1075 let measured_wrap_width = self.measured_wrap_width.clone();
1076 let node_id = self.refs.node_id.clone();
1077 let pan_resolver = self.cached_pan_resolver.clone();
1078
1079 Some(Rc::new(move |scope| {
1080 let size = scope.size();
1081 if !*is_focused.borrow() {
1082 return;
1083 }
1084 let selection = state.selection();
1085 if selection.collapsed() {
1086 return;
1087 }
1088 let text = state.text();
1089 let padding_left = content_offset.get();
1090 let padding_top = content_y_offset.get();
1091 let line_height = cached_line_height.get();
1092 let (viewport_width, viewport_height) =
1093 content_viewport(measured_size.get(), size, padding_left, padding_top);
1094 let pan = pan_resolver(viewport_width);
1095 let clip_bounds = cranpose_ui_graphics::Rect {
1096 x: padding_left,
1097 y: padding_top,
1098 width: viewport_width,
1099 height: viewport_height,
1100 };
1101
1102 let mut primitives = Vec::new();
1103 let (box_off, box_h) = crate::text::glyph_line_box(&style, line_height);
1104 for sel_rect in range_visual_line_rects(
1105 &text,
1106 &style,
1107 node_id.get(),
1108 measured_wrap_width.get(),
1109 padding_left,
1110 padding_top,
1111 pan,
1112 line_height,
1113 selection.min(),
1114 selection.max(),
1115 ) {
1116 let sel_rect = cranpose_ui_graphics::Rect {
1117 y: sel_rect.y + box_off,
1118 height: box_h,
1119 ..sel_rect
1120 };
1121 if let Some(clipped) = intersect_rect(sel_rect, clip_bounds) {
1122 primitives.push(DrawPrimitive::Rect {
1123 rect: clipped,
1124 brush: selection_brush.clone(),
1125 stroke: None,
1126 });
1127 }
1128 }
1129 scope.push_recorded(primitives);
1130 }))
1131 }
1132}
1133
1134impl SemanticsNode for TextFieldModifierNode {
1135 fn merge_semantics(&self, config: &mut SemanticsConfiguration) {
1136 let text = self.state.text();
1137 config.content_description = Some(text);
1138 config.is_editable_text = true;
1139 config.text_selection = Some(self.state.selection());
1140 }
1141}
1142
1143impl PointerInputNode for TextFieldModifierNode {
1144 fn on_pointer_event(
1145 &mut self,
1146 _context: &mut dyn ModifierNodeContext,
1147 _event: &PointerEvent,
1148 ) -> bool {
1149 false
1150 }
1151
1152 fn hit_test(&self, x: f32, y: f32) -> bool {
1153 let size = self.measured_size.get();
1154 x >= 0.0 && x <= size.width && y >= 0.0 && y <= size.height
1155 }
1156
1157 fn pointer_input_handler(&self) -> Option<Rc<dyn Fn(PointerEvent)>> {
1158 Some(self.cached_handler.clone())
1159 }
1160}
1161
1162#[derive(Clone)]
1169pub struct TextFieldElement {
1170 state: TextFieldState,
1171 style: TextStyle,
1172 cursor_color: Color,
1173 line_limits: TextFieldLineLimits,
1174 handle_controller: Option<TextFieldHandleController>,
1175 modal_depth: usize,
1176}
1177
1178impl TextFieldElement {
1179 pub fn new(state: TextFieldState, style: TextStyle) -> Self {
1181 Self {
1182 state,
1183 style,
1184 cursor_color: DEFAULT_CURSOR_COLOR,
1185 line_limits: TextFieldLineLimits::default(),
1186 handle_controller: None,
1187 modal_depth: 0,
1188 }
1189 }
1190
1191 pub fn with_cursor_color(mut self, color: Color) -> Self {
1193 self.cursor_color = color;
1194 self
1195 }
1196
1197 pub fn with_line_limits(mut self, line_limits: TextFieldLineLimits) -> Self {
1199 self.line_limits = line_limits;
1200 self
1201 }
1202
1203 pub fn with_handle_controller(mut self, controller: TextFieldHandleController) -> Self {
1205 self.handle_controller = Some(controller);
1206 self
1207 }
1208
1209 pub fn with_modal_depth(mut self, depth: usize) -> Self {
1212 self.modal_depth = depth;
1213 self
1214 }
1215}
1216
1217impl std::fmt::Debug for TextFieldElement {
1218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1219 f.debug_struct("TextFieldElement")
1220 .field("text", &self.state.text())
1221 .field("style", &self.style)
1222 .field("cursor_color", &self.cursor_color)
1223 .finish()
1224 }
1225}
1226
1227impl Hash for TextFieldElement {
1228 fn hash<H: Hasher>(&self, state: &mut H) {
1229 self.state.id().hash(state);
1230 self.cursor_color.0.to_bits().hash(state);
1231 self.cursor_color.1.to_bits().hash(state);
1232 self.cursor_color.2.to_bits().hash(state);
1233 self.cursor_color.3.to_bits().hash(state);
1234 self.style.render_hash().hash(state);
1235 self.line_limits.hash(state);
1236 self.modal_depth.hash(state);
1237 }
1238}
1239
1240impl PartialEq for TextFieldElement {
1241 fn eq(&self, other: &Self) -> bool {
1242 self.state == other.state
1243 && self.style == other.style
1244 && self.cursor_color == other.cursor_color
1245 && self.line_limits == other.line_limits
1246 && self.modal_depth == other.modal_depth
1247 }
1248}
1249
1250impl Eq for TextFieldElement {}
1251
1252impl ModifierNodeElement for TextFieldElement {
1253 type Node = TextFieldModifierNode;
1254
1255 fn create(&self) -> Self::Node {
1256 let mut node = TextFieldModifierNode::new(self.state, self.style.clone())
1257 .with_cursor_color(self.cursor_color)
1258 .with_line_limits(self.line_limits);
1259 node.modal_depth = self.modal_depth;
1260 if let Some(controller) = self.handle_controller.clone() {
1261 node = node.with_handle_controller(controller);
1262 }
1263 node.rebuild_cached_closures();
1264 node
1265 }
1266
1267 fn update(&self, node: &mut Self::Node) {
1268 node.state = self.state;
1269 node.style = self.style.clone();
1270 node.cursor_brush = Brush::solid(self.cursor_color);
1271 node.line_limits = self.line_limits;
1272 node.handle_controller = self.handle_controller.clone();
1273 node.modal_depth = self.modal_depth;
1274 node.rebuild_cached_closures();
1275
1276 if node.update_cached_state() {}
1277 }
1278
1279 fn capabilities(&self) -> NodeCapabilities {
1280 NodeCapabilities::LAYOUT
1281 | NodeCapabilities::DRAW
1282 | NodeCapabilities::SEMANTICS
1283 | NodeCapabilities::POINTER_INPUT
1284 }
1285
1286 fn always_update(&self) -> bool {
1287 true
1288 }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use std::sync::Arc;
1294
1295 use cranpose_core::{DefaultScheduler, Runtime};
1296
1297 use super::*;
1298 use crate::text::TextStyle;
1299
1300 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1301 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1302 f()
1303 }
1304
1305 #[test]
1306 fn text_field_node_creation() {
1307 let _app_context = crate::render_state::app_context_test_scope();
1308 with_test_runtime(|| {
1309 let state = TextFieldState::new("Hello");
1310 let node = TextFieldModifierNode::new(state, TextStyle::default());
1311 assert_eq!(node.text(), "Hello");
1312 assert!(!node.is_focused());
1313 });
1314 }
1315
1316 #[test]
1317 fn selection_rects_follow_wrapped_visual_lines() {
1318 let _app_context = crate::render_state::app_context_test_scope();
1319 let text = "aaaaa\nbb";
1320 let style = TextStyle::default();
1321 let line_height = 10.0_f32;
1322
1323 let rects = range_visual_line_rects(
1324 text,
1325 &style,
1326 None,
1327 Some(30.0),
1328 0.0,
1329 0.0,
1330 0.0,
1331 line_height,
1332 6,
1333 8,
1334 );
1335 assert_eq!(rects.len(), 1, "one visual line touched, got {rects:?}");
1336 assert_eq!(
1337 rects[0].y,
1338 2.0 * line_height,
1339 "highlight must land on visual line 2, not logical line 1"
1340 );
1341 assert!(rects[0].width > 0.0);
1342
1343 let spanning = range_visual_line_rects(
1344 text,
1345 &style,
1346 None,
1347 Some(30.0),
1348 0.0,
1349 0.0,
1350 0.0,
1351 line_height,
1352 0,
1353 5,
1354 );
1355 assert_eq!(spanning.len(), 2, "wrapped line spans two visual rows");
1356 assert_eq!(spanning[0].y, 0.0);
1357 assert_eq!(spanning[1].y, line_height);
1358 }
1359
1360 #[test]
1361 fn tap_resolves_offset_on_wrapped_visual_line() {
1362 let _app_context = crate::render_state::app_context_test_scope();
1363 let text = "aaaaa\nbb";
1364 let style = TextStyle::default();
1365 let line_height = 10.0_f32;
1366
1367 let off = crate::text::offset_for_position_wrapped(
1368 text,
1369 &style,
1370 None,
1371 Some(30.0),
1372 line_height,
1373 8.0,
1374 22.0,
1375 );
1376 assert!(
1377 (6..=8).contains(&off),
1378 "tap on visual line 'bb' resolved to {off}, expected 6..=8"
1379 );
1380
1381 let off1 = crate::text::offset_for_position_wrapped(
1382 text,
1383 &style,
1384 None,
1385 Some(30.0),
1386 line_height,
1387 4.0,
1388 12.0,
1389 );
1390 assert!(
1391 (3..=5).contains(&off1),
1392 "tap on wrapped 'aa' resolved to {off1}, expected 3..=5"
1393 );
1394
1395 let off2 = crate::text::offset_for_position_wrapped(
1396 "hello",
1397 &style,
1398 None,
1399 None,
1400 line_height,
1401 0.0,
1402 0.0,
1403 );
1404 assert_eq!(off2, 0);
1405 }
1406
1407 #[test]
1408 fn text_field_node_focus() {
1409 let _app_context = crate::render_state::app_context_test_scope();
1410 with_test_runtime(|| {
1411 let state = TextFieldState::new("Test");
1412 let mut node = TextFieldModifierNode::new(state, TextStyle::default());
1413 assert!(!node.is_focused());
1414
1415 node.set_focused(true);
1416 assert!(node.is_focused());
1417
1418 node.set_focused(false);
1419 assert!(!node.is_focused());
1420 });
1421 }
1422
1423 #[test]
1424 fn text_field_element_creates_node() {
1425 let _app_context = crate::render_state::app_context_test_scope();
1426 with_test_runtime(|| {
1427 let state = TextFieldState::new("Hello World");
1428 let element = TextFieldElement::new(state, TextStyle::default());
1429
1430 let node = element.create();
1431 assert_eq!(node.text(), "Hello World");
1432 });
1433 }
1434
1435 #[test]
1436 fn every_primary_pointer_source_publishes_direct_manipulation_metrics() {
1437 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1438 use cranpose_ui_graphics::Point;
1439
1440 let _app_context = crate::render_state::app_context_test_scope();
1441 with_test_runtime(|| {
1442 let state = TextFieldState::new("hello world");
1443 let controller = TextFieldHandleController::new();
1444 let mut node = TextFieldModifierNode::new(state, TextStyle::default())
1445 .with_handle_controller(controller.clone());
1446 node.measured_size.set(Size {
1447 width: 120.0,
1448 height: 20.0,
1449 });
1450
1451 let handler = node
1452 .pointer_input_handler()
1453 .expect("field exposes a pointer handler");
1454 let draw = node
1455 .create_draw_closure()
1456 .expect("field exposes a draw closure");
1457 let at = Point { x: 12.0, y: 8.0 };
1458 let size = Size {
1459 width: 120.0,
1460 height: 20.0,
1461 };
1462 let run_draw = || {
1463 let mut scope = crate::draw::command_draw_scope(size);
1464 draw(&mut scope);
1465 };
1466
1467 node.set_focused(true);
1468 run_draw();
1469 let keyboard_metrics = controller
1470 .metrics()
1471 .expect("focused field publishes handle metrics");
1472 assert!(!keyboard_metrics.direct_manipulation);
1473
1474 handler(
1475 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1476 );
1477 run_draw();
1478 let metrics = controller
1479 .metrics()
1480 .expect("focused field publishes handle metrics");
1481 assert!(metrics.focused, "a tap focuses the field");
1482 assert!(
1483 metrics.direct_manipulation,
1484 "a touch tap must expose direct-manipulation handles"
1485 );
1486 assert!(
1487 controller.press().is_some(),
1488 "touch must publish the live press"
1489 );
1490
1491 handler(
1492 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Mouse),
1493 );
1494 run_draw();
1495 let metrics = controller
1496 .metrics()
1497 .expect("focused field publishes handle metrics");
1498 assert!(
1499 metrics.direct_manipulation,
1500 "a mouse tap must expose the same direct-manipulation handles"
1501 );
1502 assert!(
1503 controller.press().is_some(),
1504 "mouse must publish the live press"
1505 );
1506
1507 handler(
1508 PointerEvent::new(PointerEventKind::Down, at, at)
1509 .with_source(PointerSource::Stylus),
1510 );
1511 run_draw();
1512 let metrics = controller
1513 .metrics()
1514 .expect("focused field publishes handle metrics");
1515 assert!(
1516 metrics.direct_manipulation,
1517 "a stylus contact must expose the same direct-manipulation handles"
1518 );
1519 assert!(
1520 controller.press().is_some(),
1521 "stylus must publish the live press"
1522 );
1523
1524 crate::text_field_focus::clear_focus();
1525 });
1526 }
1527
1528 #[test]
1529 fn double_tap_selects_the_word_under_the_finger() {
1530 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1531 use cranpose_ui_graphics::Point;
1532
1533 let _app_context = crate::render_state::app_context_test_scope();
1534 with_test_runtime(|| {
1535 let state = TextFieldState::new("hello world");
1536 let node = TextFieldModifierNode::new(state, TextStyle::default());
1537 node.measured_size.set(Size {
1538 width: 200.0,
1539 height: 20.0,
1540 });
1541 let handler = node
1542 .pointer_input_handler()
1543 .expect("field exposes a pointer handler");
1544
1545 let at = Point { x: 2.0, y: 8.0 };
1546 handler(
1547 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1548 );
1549 handler(
1550 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1551 );
1552
1553 let selection = state.selection();
1554 assert!(
1555 !selection.collapsed(),
1556 "a double tap must produce a (word) selection, got {selection:?}"
1557 );
1558 let selected = &state.text()[selection.min()..selection.max()];
1559 assert_eq!(
1560 selected, "hello",
1561 "double tap should select the whole word under the finger"
1562 );
1563
1564 crate::text_field_focus::clear_focus();
1565 });
1566 }
1567
1568 #[test]
1569 fn repeated_taps_escalate_word_line_paragraph_then_cycle() {
1570 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1571 use cranpose_ui_graphics::Point;
1572
1573 let _app_context = crate::render_state::app_context_test_scope();
1574 with_test_runtime(|| {
1575 let text = "alpha beta\ngamma delta\n\nsecond para";
1576 let state = TextFieldState::new(text);
1577 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
1578 TextFieldLineLimits::MultiLine {
1579 min_lines: 1,
1580 max_lines: usize::MAX,
1581 },
1582 );
1583 node.measured_size.set(Size {
1584 width: 400.0,
1585 height: 80.0,
1586 });
1587 let handler = node
1588 .pointer_input_handler()
1589 .expect("field exposes a pointer handler");
1590
1591 let at = Point { x: 2.0, y: 4.0 };
1592 let tap = || {
1593 handler(
1594 PointerEvent::new(PointerEventKind::Down, at, at)
1595 .with_source(PointerSource::Touch),
1596 );
1597 };
1598 let selected = |state: &TextFieldState| {
1599 let s = state.selection();
1600 state.text()[s.min()..s.max()].to_string()
1601 };
1602
1603 tap();
1604 assert!(state.selection().collapsed(), "first tap places the caret");
1605 tap();
1606 assert_eq!(selected(&state), "alpha", "double tap selects the word");
1607 tap();
1608 assert_eq!(
1609 selected(&state),
1610 "alpha beta",
1611 "triple tap selects the line"
1612 );
1613 tap();
1614 assert_eq!(
1615 selected(&state),
1616 "alpha beta\ngamma delta",
1617 "fourth tap grows to the paragraph"
1618 );
1619 tap();
1620 assert_eq!(
1621 selected(&state),
1622 "alpha",
1623 "fifth tap cycles back to the word"
1624 );
1625
1626 crate::text_field_focus::clear_focus();
1627 });
1628 }
1629
1630 #[test]
1631 fn single_tap_inside_selection_selects_the_word() {
1632 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1633 use cranpose_ui_graphics::Point;
1634
1635 let _app_context = crate::render_state::app_context_test_scope();
1636 with_test_runtime(|| {
1637 let state = TextFieldState::new("hello world");
1638 let node = TextFieldModifierNode::new(state, TextStyle::default());
1639 node.measured_size.set(Size {
1640 width: 200.0,
1641 height: 20.0,
1642 });
1643 let handler = node
1644 .pointer_input_handler()
1645 .expect("field exposes a pointer handler");
1646
1647 state.edit(|buffer| buffer.select(TextRange::new(0, 11)));
1648 assert!(!state.selection().collapsed());
1649
1650 let at = Point { x: 2.0, y: 8.0 };
1651 handler(
1652 PointerEvent::new(PointerEventKind::Down, at, at).with_source(PointerSource::Touch),
1653 );
1654
1655 let selection = state.selection();
1656 assert!(
1657 !selection.collapsed(),
1658 "a tap inside a selection must not collapse it, got {selection:?}"
1659 );
1660 assert_eq!(
1661 &state.text()[selection.min()..selection.max()],
1662 "hello",
1663 "a tap inside a selection re-selects the word under the finger"
1664 );
1665
1666 crate::text_field_focus::clear_focus();
1667 });
1668 }
1669
1670 #[test]
1671 fn slow_taps_inside_selection_cycle_word_line_paragraph_by_location() {
1672 use cranpose_foundation::{PointerEvent, PointerEventKind, PointerSource};
1673 use cranpose_ui_graphics::Point;
1674
1675 let _app_context = crate::render_state::app_context_test_scope();
1676 with_test_runtime(|| {
1677 let text = "alpha beta\ngamma delta\n\nsecond para";
1678 let state = TextFieldState::new(text);
1679 let node = TextFieldModifierNode::new(state, TextStyle::default()).with_line_limits(
1680 TextFieldLineLimits::MultiLine {
1681 min_lines: 1,
1682 max_lines: usize::MAX,
1683 },
1684 );
1685 node.measured_size.set(Size {
1686 width: 400.0,
1687 height: 80.0,
1688 });
1689 let handler = node
1690 .pointer_input_handler()
1691 .expect("field exposes a pointer handler");
1692
1693 state.edit(|buffer| buffer.select(TextRange::new(0, text.len())));
1694
1695 let at = Point { x: 2.0, y: 4.0 };
1696 let selected = |state: &TextFieldState| {
1697 let s = state.selection();
1698 state.text()[s.min()..s.max()].to_string()
1699 };
1700 let slow_tap = || {
1701 node.refs.last_click_time.set(None);
1702 handler(
1703 PointerEvent::new(PointerEventKind::Down, at, at)
1704 .with_source(PointerSource::Touch),
1705 );
1706 };
1707
1708 slow_tap();
1709 assert_eq!(
1710 selected(&state),
1711 "alpha",
1712 "tap inside selection grabs the word"
1713 );
1714 slow_tap();
1715 assert_eq!(
1716 selected(&state),
1717 "alpha beta",
1718 "same-spot tap grows to the line even after the timeout"
1719 );
1720 slow_tap();
1721 assert_eq!(
1722 selected(&state),
1723 "alpha beta\ngamma delta",
1724 "same-spot tap grows to the paragraph"
1725 );
1726 slow_tap();
1727 assert_eq!(
1728 selected(&state),
1729 "alpha",
1730 "same-spot tap cycles back to the word"
1731 );
1732
1733 crate::text_field_focus::clear_focus();
1734 });
1735 }
1736
1737 #[test]
1738 fn text_field_element_equality() {
1739 let _app_context = crate::render_state::app_context_test_scope();
1740 with_test_runtime(|| {
1741 let state1 = TextFieldState::new("Hello");
1742 let state2 = TextFieldState::new("Hello");
1743
1744 let elem1 = TextFieldElement::new(state1, TextStyle::default());
1745 let elem2 = TextFieldElement::new(state1, TextStyle::default());
1746 let elem3 = TextFieldElement::new(state2, TextStyle::default());
1747
1748 assert_eq!(elem1, elem2, "Same state should be equal");
1749 assert_ne!(elem1, elem3, "Different states should not be equal");
1750 });
1751 }
1752
1753 #[test]
1754 fn text_field_element_update_refreshes_existing_node_style() {
1755 let _app_context = crate::render_state::app_context_test_scope();
1756 with_test_runtime(|| {
1757 let state = TextFieldState::new("themed text");
1758 let dark_style = TextStyle::from_span_style(crate::text::SpanStyle {
1759 color: Some(Color::from_rgba_u8(228, 240, 252, 255)),
1760 ..crate::text::SpanStyle::default()
1761 });
1762 let light_style = TextStyle::from_span_style(crate::text::SpanStyle {
1763 color: Some(Color::from_rgba_u8(14, 58, 96, 255)),
1764 ..crate::text::SpanStyle::default()
1765 });
1766 let initial = TextFieldElement::new(state, dark_style);
1767 let updated = TextFieldElement::new(state, light_style.clone());
1768 let mut node = initial.create();
1769
1770 updated.update(&mut node);
1771
1772 assert_eq!(node.text(), "themed text");
1773 assert_eq!(node.style(), &light_style);
1774 });
1775 }
1776
1777 #[test]
1778 fn multiline_field_measures_wrapped_height() {
1779 let _app_context = crate::render_state::app_context_test_scope();
1780 with_test_runtime(|| {
1781 let long = "abcd ".repeat(40);
1782 let state = TextFieldState::new(&long);
1783 let node = TextFieldModifierNode::new(state, TextStyle::default());
1784 assert!(
1785 !node.line_limits().is_single_line(),
1786 "default fields are multi-line"
1787 );
1788
1789 let natural = node.measure_text_content(None);
1790 let wrapped = node.measure_text_content(node.wrap_width(20.0));
1791
1792 assert!(
1793 wrapped.height > natural.height,
1794 "wrapped multi-line height {} must exceed the single-line height {}",
1795 wrapped.height,
1796 natural.height
1797 );
1798 });
1799 }
1800
1801 #[test]
1802 fn single_line_field_never_wraps() {
1803 let _app_context = crate::render_state::app_context_test_scope();
1804 with_test_runtime(|| {
1805 let state = TextFieldState::new("abcd ".repeat(40));
1806 let node = TextFieldModifierNode::new(state, TextStyle::default())
1807 .with_line_limits(TextFieldLineLimits::SingleLine);
1808 assert_eq!(
1809 node.wrap_width(20.0),
1810 None,
1811 "single-line fields must not wrap"
1812 );
1813 });
1814 }
1815
1816 #[test]
1817 fn test_cursor_x_position_calculation() {
1818 let _app_context = crate::render_state::app_context_test_scope();
1819 with_test_runtime(|| {
1820 let style = crate::text::TextStyle::default();
1821
1822 let empty_width =
1823 crate::text::measure_text(&crate::text::AnnotatedString::from(""), &style).width;
1824 assert!(
1825 empty_width.abs() < 0.1,
1826 "Empty text should have 0 width, got {}",
1827 empty_width
1828 );
1829
1830 let hi_width =
1831 crate::text::measure_text(&crate::text::AnnotatedString::from("Hi"), &style).width;
1832 assert!(
1833 hi_width > 0.0,
1834 "Text 'Hi' should have positive width: {}",
1835 hi_width
1836 );
1837
1838 let h_width =
1839 crate::text::measure_text(&crate::text::AnnotatedString::from("H"), &style).width;
1840 assert!(h_width > 0.0, "Text 'H' should have positive width");
1841 assert!(
1842 h_width < hi_width,
1843 "'H' width {} should be less than 'Hi' width {}",
1844 h_width,
1845 hi_width
1846 );
1847
1848 let state = TextFieldState::new("Hi");
1849 assert_eq!(
1850 state.selection().start,
1851 2,
1852 "Cursor should be at position 2 (end of 'Hi')"
1853 );
1854
1855 let text = state.text();
1856 let cursor_pos = state.selection().start;
1857 let text_before_cursor = &text[..cursor_pos.min(text.len())];
1858 assert_eq!(text_before_cursor, "Hi");
1859
1860 let cursor_x = crate::text::measure_text(
1861 &crate::text::AnnotatedString::from(text_before_cursor),
1862 &style,
1863 )
1864 .width;
1865 assert!(
1866 (cursor_x - hi_width).abs() < 0.1,
1867 "Cursor x {} should equal 'Hi' width {}",
1868 cursor_x,
1869 hi_width
1870 );
1871 });
1872 }
1873
1874 #[test]
1875 fn test_focused_node_creates_cursor() {
1876 let _app_context = crate::render_state::app_context_test_scope();
1877 with_test_runtime(|| {
1878 let state = TextFieldState::new("Test");
1879 let element = TextFieldElement::new(state, TextStyle::default());
1880 let node = element.create();
1881
1882 assert!(!node.is_focused());
1883
1884 *node.refs.is_focused.borrow_mut() = true;
1885 assert!(node.is_focused());
1886
1887 assert_eq!(node.text(), "Test");
1888
1889 assert_eq!(node.selection().start, 4);
1890 });
1891 }
1892}