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