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