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