1#![allow(non_snake_case)]
7
8use crate::bring_into_view::local_bring_into_view_responder;
9use crate::clipboard_session::{
10 clipboard_can_paste, clipboard_paste_into_focus, clipboard_write_text,
11};
12use crate::composable;
13use crate::layout::policies::EmptyMeasurePolicy;
14use crate::modifier::Modifier;
15use crate::safe_area::local_ime_insets;
16use crate::text::{measure_text, AnnotatedString, TextStyle};
17use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_select_all};
18use crate::text_field_modifier_node::{
19 TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
20};
21use crate::text_selection::{
22 selection_after_handle_drag, HandleGrabOffset, HandleKind, LineAffinity, HANDLE_RADIUS,
23};
24use crate::widgets::{
25 loupe_target_for_drag, CaretActionMenu, Layout, SelectionHandle, SelectionLoupe,
26 TextSelectionMenu,
27};
28use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
29use cranpose_foundation::modifier_element;
30use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
31use cranpose_ui_graphics::{Color, Point, Rect};
32use std::cell::{Cell, RefCell};
33use std::rc::{Rc, Weak};
34
35pub const SELECTION_HIGHLIGHT_ALPHA: f32 = 0.32;
40
41const TEXT_LONG_PRESS_MS: u64 = 500;
44const TEXT_LONG_PRESS_SLOP: f32 = 12.0;
47
48struct LongPressWatcher {
55 controller: TextFieldHandleController,
56 state: TextFieldState,
57 style: TextStyle,
58 start: Point,
59 start_nanos: Cell<Option<u64>>,
60 registration: RefCell<Option<cranpose_core::internal::FrameCallbackRegistration>>,
61 frame_clock: cranpose_core::internal::FrameClock,
62}
63
64impl LongPressWatcher {
65 fn arm(self: &Rc<Self>) {
66 let weak: Weak<LongPressWatcher> = Rc::downgrade(self);
67 let registration = self.frame_clock.with_frame_nanos(move |now| {
68 let Some(watcher) = weak.upgrade() else {
69 return;
70 };
71 watcher.tick(now);
72 });
73 *self.registration.borrow_mut() = Some(registration);
74 crate::request_render_invalidation();
77 }
78
79 fn tick(self: Rc<Self>, now: u64) {
80 self.registration.borrow_mut().take();
81 let Some(press) = self.controller.metrics().and_then(|m| m.press) else {
82 return; };
84 let moved = (press.position.x - self.start.x)
85 .abs()
86 .max((press.position.y - self.start.y).abs());
87 if (press.start.x - self.start.x).abs() > 0.5
88 || (press.start.y - self.start.y).abs() > 0.5
89 || moved > TEXT_LONG_PRESS_SLOP
90 {
91 return; }
93 let start = match self.start_nanos.get() {
94 Some(value) => value,
95 None => {
96 self.start_nanos.set(Some(now));
97 now
98 }
99 };
100 if now.saturating_sub(start) < TEXT_LONG_PRESS_MS * 1_000_000 {
101 self.arm();
102 return;
103 }
104 self.controller.claim_gesture();
108 let Some(metrics) = self.controller.metrics() else {
109 return;
110 };
111 let text = self.state.text();
112 let offset = window_pos_to_offset(&text, &self.style, &metrics, self.start, 0.0);
113 let (word_start, word_end) = crate::word_boundaries::find_word_boundaries(&text, offset);
114 self.state.edit(|buffer| {
115 buffer.select(TextRange::new(word_start, word_end));
116 });
117 crate::request_render_invalidation();
118 }
119}
120
121fn handle_tip_window_pos(
127 text: &str,
128 style: &TextStyle,
129 metrics: &TextFieldHandleMetrics,
130 offset: usize,
131 affinity: LineAffinity,
132) -> Point {
133 let offset = offset.min(text.len());
134 let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
138 text,
139 style,
140 None,
141 metrics.wrap_width,
142 offset,
143 affinity,
144 );
145 let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
146 Point {
147 x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
148 y: metrics.node_origin.y
151 + metrics.padding_top
152 + line_index as f32 * metrics.line_height
153 + metrics.glyph_box.0
154 + metrics.glyph_box.1,
155 }
156}
157
158fn window_pos_to_offset(
165 text: &str,
166 style: &TextStyle,
167 metrics: &TextFieldHandleMetrics,
168 window_pos: Point,
169 y_bias: f32,
170) -> usize {
171 let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
172 + metrics.scroll_offset)
173 .max(0.0);
174 let local_y = (window_pos.y + y_bias
177 - 0.5 * metrics.line_height
178 - metrics.node_origin.y
179 - metrics.padding_top)
180 .max(0.0);
181 crate::text::offset_for_position_wrapped(
186 text,
187 style,
188 None,
189 metrics.wrap_width,
190 metrics.line_height,
191 local_x,
192 local_y,
193 )
194}
195#[composable]
213pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
214 BasicTextFieldWithOptions(
215 state,
216 modifier,
217 BasicTextFieldOptions {
218 text_style: style,
219 ..BasicTextFieldOptions::default()
220 },
221 )
222}
223
224#[derive(Debug, Clone, PartialEq)]
226pub struct BasicTextFieldOptions {
227 pub text_style: TextStyle,
229 pub cursor_color: Color,
231 pub line_limits: TextFieldLineLimits,
233}
234
235impl Default for BasicTextFieldOptions {
236 fn default() -> Self {
237 Self {
238 text_style: TextStyle::default(),
239 cursor_color: Color(0.0, 0.478, 1.0, 1.0),
242 line_limits: TextFieldLineLimits::default(),
243 }
244 }
245}
246
247#[composable]
251pub fn BasicTextFieldWithOptions(
252 state: TextFieldState,
253 modifier: Modifier,
254 options: BasicTextFieldOptions,
255) -> NodeId {
256 let _text = state.text();
259 let _selection = state.selection();
260
261 let controller =
265 remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);
266
267 let text_field_element = TextFieldElement::new(state, options.text_style.clone())
269 .with_cursor_color(options.cursor_color)
270 .with_line_limits(options.line_limits)
271 .with_handle_controller(controller.clone());
272
273 let text_field_modifier = modifier_element(text_field_element);
275 let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
276 let combined_modifier = modifier.then(final_modifier);
277
278 let node = Layout(
281 combined_modifier,
282 EmptyMeasurePolicy,
283 || {}, );
285
286 BringCaretIntoView(state, options.text_style.clone(), controller.clone());
291
292 SelectionHandles(state, options.text_style, controller, options.cursor_color);
299
300 node
301}
302
303fn caret_window_rect(
307 text: &str,
308 style: &TextStyle,
309 metrics: &TextFieldHandleMetrics,
310 offset: usize,
311) -> Rect {
312 let tip = handle_tip_window_pos(text, style, metrics, offset, LineAffinity::Upstream);
315 Rect {
316 x: tip.x,
317 y: tip.y - metrics.glyph_box.1,
318 width: 2.0,
319 height: metrics.glyph_box.1,
320 }
321}
322
323#[composable]
333fn BringCaretIntoView(
334 state: TextFieldState,
335 style: TextStyle,
336 controller: TextFieldHandleController,
337) {
338 let Some(metrics) = controller.metrics() else {
339 return;
340 };
341 let ime_bottom = local_ime_insets().current().bottom;
344 let responder = local_bring_into_view_responder().current();
345
346 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
347 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
348
349 if !metrics.focused {
350 previous.set(None);
353 return;
354 }
355 let Some(responder) = responder else {
356 return;
357 };
358
359 let text = state.text();
360 let selection = state.selection();
361 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
362
363 let key = (
367 selection.start,
368 selection.end,
369 (ime_bottom * 4.0).round() as i64,
370 );
371 SideEffect(move || {
372 if previous.get() != Some(key) {
373 previous.set(Some(key));
374 responder.bring_into_view(caret, ime_bottom);
375 }
376 });
377}
378
379#[composable]
384fn SelectionHandles(
385 state: TextFieldState,
386 style: TextStyle,
387 controller: TextFieldHandleController,
388 accent: Color,
389) {
390 let selection = state.selection();
391 let current_range = (selection.min(), selection.max());
392
393 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
397 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
402 let caret_menu_offset: Rc<Cell<usize>> =
405 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
406 let previous_range: Rc<Cell<(usize, usize)>> =
407 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
408 {
409 let previous_range = Rc::clone(&previous_range);
410 SideEffect(move || {
411 if previous_range.get() != current_range {
412 previous_range.set(current_range);
413 menu_open.set(true);
414 }
415 });
416 }
417 {
420 let caret_menu_offset = Rc::clone(&caret_menu_offset);
421 let caret_start = selection.start;
422 SideEffect(move || {
423 if caret_menu_open.value()
424 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
425 {
426 caret_menu_open.set(false);
427 }
428 });
429 }
430
431 let Some(metrics) = controller.metrics() else {
432 return;
433 };
434 if !metrics.focused || !metrics.direct_manipulation {
435 return;
436 }
437
438 let text = state.text();
439
440 let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
446 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
447 let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
448 remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
449 match metrics.press {
450 Some(press) => {
451 let key = (press.start.x.to_bits(), press.start.y.to_bits());
452 if press_watcher.get() != Some(key) {
453 press_watcher.set(Some(key));
454 let watcher = Rc::new(LongPressWatcher {
455 controller: controller.clone(),
456 state,
457 style: style.clone(),
458 start: press.start,
459 start_nanos: Cell::new(None),
460 registration: RefCell::new(None),
461 frame_clock: cranpose_core::with_current_composer(|composer| {
462 composer.runtime_handle()
463 })
464 .frame_clock(),
465 });
466 watcher.arm();
467 *press_watcher_ref.borrow_mut() = Some(watcher);
468 }
469 }
470 None => {
471 press_watcher.set(None);
472 press_watcher_ref.borrow_mut().take();
473 }
474 }
475
476 let drag_pos: MutableState<Option<Point>> =
480 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
481 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
484 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
485 let last_dragged: Rc<Cell<Option<HandleKind>>> =
490 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
491 let menu_anchor_range: Rc<Cell<(usize, usize)>> =
492 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
493 {
494 let last_dragged = Rc::clone(&last_dragged);
495 let menu_anchor_range = Rc::clone(&menu_anchor_range);
496 SideEffect(move || {
497 if menu_anchor_range.get() != current_range {
498 menu_anchor_range.set(current_range);
499 if drag_pos.value().is_none() {
500 last_dragged.set(None);
501 }
502 }
503 });
504 }
505 let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
513 let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
514 let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
515
516 if selection.collapsed() {
517 let tip = handle_tip_window_pos(
519 &text,
520 &style,
521 &metrics,
522 selection.start,
523 LineAffinity::Upstream,
524 );
525 let on_drag = drag_caret_closure(
526 state,
527 style.clone(),
528 controller.clone(),
529 Rc::clone(&drag_bias),
530 );
531 let open_caret_menu = {
534 let caret_menu_offset = Rc::clone(&caret_menu_offset);
535 move || {
536 caret_menu_offset.set(state.selection().start);
537 caret_menu_open.set(true);
538 }
539 };
540 let on_tap = open_caret_menu.clone();
541 let on_long_press = open_caret_menu;
542 let grab_bias = Rc::clone(&drag_bias);
543 let end_bias = Rc::clone(&drag_bias);
544 cursor_tip_y.set(tip.y);
545 let tip_y = Rc::clone(&cursor_tip_y);
546 SelectionHandle(
547 HandleKind::Cursor,
548 tip,
549 metrics.glyph_box.1,
550 HANDLE_RADIUS,
551 accent,
552 move |pos| {
553 track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
554 drag_pos.set(Some(pos));
555 on_drag(pos);
556 },
557 move || {
558 drag_pos.set(None);
559 end_bias.set(None);
560 crate::cursor_animation::reset_cursor_blink();
563 },
564 on_long_press,
565 on_tap,
566 );
567
568 if caret_menu_open.value() {
573 let can_paste = clipboard_can_paste();
574 let can_undo = state.can_undo();
575 let can_redo = state.can_redo();
576 let undo_state = state;
577 let redo_state = state;
578 CaretActionMenu(
579 tip.x,
580 tip.y - metrics.glyph_box.1,
581 drag_pos.value().is_none(),
582 can_paste,
583 can_undo,
584 can_redo,
585 move || {
586 clipboard_paste_into_focus();
587 caret_menu_open.set(false);
588 },
589 move || {
590 dispatch_select_all();
591 caret_menu_open.set(false);
592 },
593 move || {
594 undo_state.undo();
595 crate::request_render_invalidation();
596 caret_menu_open.set(false);
597 },
598 move || {
599 redo_state.redo();
600 crate::request_render_invalidation();
601 caret_menu_open.set(false);
602 },
603 );
604 }
605 } else {
606 let start = selection.min();
608 let end = selection.max();
609 let start_tip =
610 handle_tip_window_pos(&text, &style, &metrics, start, LineAffinity::Downstream);
611 let end_tip = handle_tip_window_pos(&text, &style, &metrics, end, LineAffinity::Upstream);
612
613 let last_dragged_start = Rc::clone(&last_dragged);
614 let last_dragged_end = Rc::clone(&last_dragged);
615 let on_drag_start = drag_edge_closure(
616 HandleKind::SelectionStart,
617 state,
618 style.clone(),
619 controller.clone(),
620 Rc::clone(&drag_bias),
621 );
622 let grab_bias = Rc::clone(&drag_bias);
623 let end_bias = Rc::clone(&drag_bias);
624 start_tip_y.set(start_tip.y);
625 let start_tip_live = Rc::clone(&start_tip_y);
626 SelectionHandle(
627 HandleKind::SelectionStart,
628 start_tip,
629 metrics.glyph_box.1,
630 HANDLE_RADIUS,
631 accent,
632 move |pos| {
633 track_handle_grab(
634 &grab_bias,
635 HandleKind::SelectionStart,
636 start_tip_live.get(),
637 pos.y,
638 );
639 last_dragged_start.set(Some(HandleKind::SelectionStart));
640 drag_pos.set(Some(pos));
641 on_drag_start(pos);
642 },
643 move || {
644 drag_pos.set(None);
645 end_bias.set(None);
646 },
647 move || menu_open.set(true),
651 move || menu_open.set(true),
653 );
654
655 let on_drag_end = drag_edge_closure(
656 HandleKind::SelectionEnd,
657 state,
658 style.clone(),
659 controller.clone(),
660 Rc::clone(&drag_bias),
661 );
662 let grab_bias = Rc::clone(&drag_bias);
663 let end_bias = Rc::clone(&drag_bias);
664 end_tip_y.set(end_tip.y);
665 let end_tip_live = Rc::clone(&end_tip_y);
666 SelectionHandle(
667 HandleKind::SelectionEnd,
668 end_tip,
669 metrics.glyph_box.1,
670 HANDLE_RADIUS,
671 accent,
672 move |pos| {
673 track_handle_grab(
674 &grab_bias,
675 HandleKind::SelectionEnd,
676 end_tip_live.get(),
677 pos.y,
678 );
679 last_dragged_end.set(Some(HandleKind::SelectionEnd));
680 drag_pos.set(Some(pos));
681 on_drag_end(pos);
682 },
683 move || {
684 drag_pos.set(None);
685 end_bias.set(None);
686 },
687 move || menu_open.set(true),
688 move || menu_open.set(true),
689 );
690
691 if menu_open.value() {
697 let can_paste = clipboard_can_paste();
698 let slide_point = if controller.gesture_claimed() {
701 metrics.press.map(|press| press.position)
702 } else {
703 None
704 };
705 let (menu_x, menu_top) = match last_dragged.get() {
709 Some(HandleKind::SelectionStart) => {
710 (start_tip.x, start_tip.y - metrics.glyph_box.1)
711 }
712 Some(HandleKind::SelectionEnd) | Some(HandleKind::Cursor) => {
713 (end_tip.x, end_tip.y - metrics.glyph_box.1)
714 }
715 None => (
716 (start_tip.x + end_tip.x) * 0.5,
717 start_tip.y - metrics.glyph_box.1,
718 ),
719 };
720 TextSelectionMenu(
721 menu_x,
722 menu_top,
723 drag_pos.value().is_none(),
724 slide_point,
725 can_paste,
726 move || {
727 if let Some(text) = dispatch_copy() {
728 clipboard_write_text(&text);
729 }
730 menu_open.set(false);
731 },
732 move || {
733 if let Some(text) = dispatch_cut() {
734 clipboard_write_text(&text);
735 }
736 menu_open.set(false);
737 },
738 move || {
739 clipboard_paste_into_focus();
740 menu_open.set(false);
741 },
742 move || {
743 dispatch_select_all();
744 menu_open.set(false);
745 },
746 );
747 }
748 }
749
750 let loupe_target = drag_pos.value().and_then(|finger| {
757 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
758 let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
759 let line_bottom =
762 handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
763 loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
764 });
765 SelectionLoupe(loupe_target);
766}
767
768fn track_handle_grab(
769 drag_bias: &Cell<Option<HandleGrabOffset>>,
770 kind: HandleKind,
771 handle_tip_y: f32,
772 finger_y: f32,
773) -> f32 {
774 let drifts = kind != HandleKind::SelectionStart;
777 let mut grab = drag_bias
778 .get()
779 .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
780 let bias = grab.track(finger_y);
781 drag_bias.set(Some(grab));
782 bias
783}
784
785fn drag_caret_closure(
789 state: TextFieldState,
790 style: TextStyle,
791 controller: TextFieldHandleController,
792 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
793) -> Rc<dyn Fn(Point)> {
794 Rc::new(move |window_pos: Point| {
795 let Some(metrics) = controller.metrics() else {
796 return;
797 };
798 let text = state.text();
799 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
800 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
801 state.set_selection(TextRange::new(offset, offset));
802 crate::cursor_animation::suspend_cursor_blink();
806 crate::request_render_invalidation();
807 })
808}
809
810fn drag_edge_closure(
814 dragged: HandleKind,
815 state: TextFieldState,
816 style: TextStyle,
817 controller: TextFieldHandleController,
818 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
819) -> Rc<dyn Fn(Point)> {
820 Rc::new(move |window_pos: Point| {
821 let Some(metrics) = controller.metrics() else {
822 return;
823 };
824 let text = state.text();
825 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
826 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
827 let selection = state.selection();
828 let fixed_edge = match dragged {
829 HandleKind::SelectionStart => selection.max(),
830 _ => selection.min(),
831 };
832 let (min, max) =
833 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
834 state.set_selection(TextRange::new(min, max));
835 crate::request_render_invalidation();
836 })
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 #[test]
844 fn handle_grab_bias_reads_the_live_tip_not_a_snapshot() {
845 let tip_y: Rc<Cell<f32>> = Rc::new(Cell::new(100.0));
851 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> = Rc::new(Cell::new(None));
852 let grab = {
853 let tip_y = Rc::clone(&tip_y);
854 let drag_bias = Rc::clone(&drag_bias);
855 move |finger_y: f32| {
856 track_handle_grab(&drag_bias, HandleKind::SelectionEnd, tip_y.get(), finger_y)
857 }
858 };
859
860 tip_y.set(148.0);
863 let bias = grab(160.0);
864 assert_eq!(
865 bias,
866 148.0 - 160.0,
867 "the grab bias must anchor on the handle's CURRENT line"
868 );
869 }
870 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
871 use std::sync::Arc;
872
873 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
875 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
876 f()
877 }
878
879 fn render_collapsed_handles(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
883 use crate::layout::LayoutEngine;
884 use crate::renderer::HeadlessRenderer;
885 use crate::widgets::PopupHost;
886 use cranpose_ui_graphics::Size;
887
888 let mut composition = Composition::new(MemoryApplier::new());
889 let key = location_key(file!(), line!(), column!());
890 let state = TextFieldState::new("hello world");
891
892 let mut content = move || {
893 PopupHost(move || {
894 let controller = TextFieldHandleController::new();
895 controller.publish(TextFieldHandleMetrics {
896 focused: true,
897 direct_manipulation,
898 node_origin: Point { x: 0.0, y: 10.0 },
899 padding_left: 0.0,
900 padding_top: 0.0,
901 scroll_offset: 0.0,
902 line_height: 18.0,
903 glyph_box: (0.0, 18.0),
904 wrap_width: None,
905 press: None,
906 });
907 SelectionHandles(
908 state,
909 TextStyle::default(),
910 controller,
911 Color(0.0, 0.478, 1.0, 1.0),
912 );
913 });
914 };
915
916 composition.render(key, &mut content).expect("render");
917 for _ in 0..16 {
918 if !composition.should_render() {
919 break;
920 }
921 composition.reconcile(key, &mut content).expect("reconcile");
922 }
923 let root = composition.root().expect("root");
924 let handle = composition.runtime_handle();
925 let mut applier = composition.applier_mut();
926 applier.set_runtime_handle(handle);
927 let layout = applier
928 .compute_layout(
929 root,
930 Size {
931 width: 400.0,
932 height: 400.0,
933 },
934 )
935 .expect("layout");
936 applier.clear_runtime_handle();
937 drop(applier);
938 HeadlessRenderer::new().render(&layout)
939 }
940
941 fn render_range_menu(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
944 use crate::layout::LayoutEngine;
945 use crate::renderer::HeadlessRenderer;
946 use crate::widgets::PopupHost;
947 use cranpose_ui_graphics::Size;
948
949 let mut composition = Composition::new(MemoryApplier::new());
950 let key = location_key(file!(), line!(), column!());
951 let state = TextFieldState::new("hello world");
952
953 let mut content = move || {
954 PopupHost(move || {
955 let controller = TextFieldHandleController::new();
956 if state.selection() != TextRange::new(0, 5) {
957 state.set_selection(TextRange::new(0, 5));
958 }
959 controller.publish(TextFieldHandleMetrics {
960 focused: true,
961 direct_manipulation,
962 node_origin: Point { x: 0.0, y: 40.0 },
963 padding_left: 0.0,
964 padding_top: 0.0,
965 scroll_offset: 0.0,
966 line_height: 18.0,
967 glyph_box: (0.0, 18.0),
968 wrap_width: None,
969 press: None,
970 });
971 SelectionHandles(
972 state,
973 TextStyle::default(),
974 controller,
975 Color(0.0, 0.478, 1.0, 1.0),
976 );
977 });
978 };
979
980 composition.render(key, &mut content).expect("render");
981 for _ in 0..16 {
982 if !composition.should_render() {
983 break;
984 }
985 composition.reconcile(key, &mut content).expect("reconcile");
986 }
987 let root = composition.root().expect("root");
988 let handle = composition.runtime_handle();
989 let mut applier = composition.applier_mut();
990 applier.set_runtime_handle(handle);
991 let layout = applier
992 .compute_layout(
993 root,
994 Size {
995 width: 400.0,
996 height: 400.0,
997 },
998 )
999 .expect("layout");
1000 applier.clear_runtime_handle();
1001 drop(applier);
1002 HeadlessRenderer::new().render(&layout)
1003 }
1004
1005 fn render_range_menu_subcomposed(
1011 direct_manipulation: bool,
1012 ) -> crate::renderer::RecordedRenderScene {
1013 use crate::layout::LayoutEngine;
1014 use crate::renderer::HeadlessRenderer;
1015 use crate::widgets::{BoxWithConstraints, PopupHost};
1016 use cranpose_ui_graphics::Size;
1017
1018 let mut composition = Composition::new(MemoryApplier::new());
1019 let key = location_key(file!(), line!(), column!());
1020 let state = TextFieldState::new("hello world");
1021
1022 let mut content = move || {
1023 PopupHost(move || {
1024 let state = state;
1025 BoxWithConstraints(
1026 Modifier::empty().size(Size {
1027 width: 300.0,
1028 height: 300.0,
1029 }),
1030 move |_scope| {
1031 let controller = TextFieldHandleController::new();
1032 if state.selection() != TextRange::new(0, 5) {
1033 state.set_selection(TextRange::new(0, 5));
1034 }
1035 controller.publish(TextFieldHandleMetrics {
1036 focused: true,
1037 direct_manipulation,
1038 node_origin: Point { x: 0.0, y: 40.0 },
1039 padding_left: 0.0,
1040 padding_top: 0.0,
1041 scroll_offset: 0.0,
1042 line_height: 18.0,
1043 glyph_box: (0.0, 18.0),
1044 wrap_width: None,
1045 press: None,
1046 });
1047 SelectionHandles(
1048 state,
1049 TextStyle::default(),
1050 controller,
1051 Color(0.0, 0.478, 1.0, 1.0),
1052 );
1053 },
1054 );
1055 });
1056 };
1057
1058 composition.render(key, &mut content).expect("render");
1059 let root = composition.root().expect("root");
1060 let handle = composition.runtime_handle();
1061 let mut scene = None;
1062 for _ in 0..8 {
1066 for _ in 0..16 {
1067 if !composition.should_render() {
1068 break;
1069 }
1070 composition.reconcile(key, &mut content).expect("reconcile");
1071 }
1072 let mut applier = composition.applier_mut();
1073 applier.set_runtime_handle(handle.clone());
1074 let layout = applier
1075 .compute_layout(
1076 root,
1077 Size {
1078 width: 400.0,
1079 height: 400.0,
1080 },
1081 )
1082 .expect("layout");
1083 applier.clear_runtime_handle();
1084 drop(applier);
1085 scene = Some(HeadlessRenderer::new().render(&layout));
1086 }
1087 scene.expect("scene")
1088 }
1089
1090 fn render_range_menu_lazy_column(
1097 direct_manipulation: bool,
1098 ) -> crate::renderer::RecordedRenderScene {
1099 use crate::layout::LayoutEngine;
1100 use crate::renderer::HeadlessRenderer;
1101 use crate::widgets::PopupHost;
1102 use crate::{LazyColumn, LazyColumnSpec};
1103 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
1104 use cranpose_ui_graphics::Size;
1105
1106 let mut composition = Composition::new(MemoryApplier::new());
1107 let key = location_key(file!(), line!(), column!());
1108 let state = TextFieldState::new("hello world");
1109
1110 let mut content = move || {
1111 PopupHost(move || {
1112 let state = state;
1113 let list_state = remember_lazy_list_state();
1114 LazyColumn(
1115 Modifier::empty().size(Size {
1116 width: 300.0,
1117 height: 300.0,
1118 }),
1119 list_state,
1120 LazyColumnSpec::default(),
1121 move |scope| {
1122 let state = state;
1123 scope.items(
1124 1,
1125 None::<fn(usize) -> u64>,
1126 None::<fn(usize) -> u64>,
1127 move |_index| {
1128 let controller = TextFieldHandleController::new();
1129 if state.selection() != TextRange::new(0, 5) {
1130 state.set_selection(TextRange::new(0, 5));
1131 }
1132 controller.publish(TextFieldHandleMetrics {
1133 focused: true,
1134 direct_manipulation,
1135 node_origin: Point { x: 0.0, y: 40.0 },
1136 padding_left: 0.0,
1137 padding_top: 0.0,
1138 scroll_offset: 0.0,
1139 line_height: 18.0,
1140 glyph_box: (0.0, 18.0),
1141 wrap_width: None,
1142 press: None,
1143 });
1144 SelectionHandles(
1145 state,
1146 TextStyle::default(),
1147 controller,
1148 Color(0.0, 0.478, 1.0, 1.0),
1149 );
1150 },
1151 );
1152 },
1153 );
1154 });
1155 };
1156
1157 composition.render(key, &mut content).expect("render");
1158 let root = composition.root().expect("root");
1159 let handle = composition.runtime_handle();
1160 let mut scene = None;
1161 for _ in 0..8 {
1162 for _ in 0..16 {
1163 if !composition.should_render() {
1164 break;
1165 }
1166 composition.reconcile(key, &mut content).expect("reconcile");
1167 }
1168 let mut applier = composition.applier_mut();
1169 applier.set_runtime_handle(handle.clone());
1170 let layout = applier
1171 .compute_layout(
1172 root,
1173 Size {
1174 width: 400.0,
1175 height: 400.0,
1176 },
1177 )
1178 .expect("layout");
1179 applier.clear_runtime_handle();
1180 drop(applier);
1181 scene = Some(HeadlessRenderer::new().render(&layout));
1182 }
1183 scene.expect("scene")
1184 }
1185
1186 #[test]
1195 fn field_window_origin_follows_vertical_scroll() {
1196 use crate::layout::policies::EmptyMeasurePolicy;
1197 use crate::layout::{LayoutBox, LayoutEngine};
1198 use crate::renderer::HeadlessRenderer;
1199 use crate::scroll::ScrollState;
1200 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
1201 use cranpose_core::{remember, Key};
1202 use cranpose_foundation::modifier_element;
1203 use cranpose_ui_graphics::Size;
1204 use std::cell::RefCell;
1205
1206 let _app_context = crate::render_state::app_context_test_scope();
1207
1208 let mut composition = Composition::new(MemoryApplier::new());
1211 let state = TextFieldState::new("hello world");
1212 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
1213 Rc::new(RefCell::new(None));
1214 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1217
1218 let spacer_before = 200.0_f32;
1219 let mut content = {
1220 let controller_slot = Rc::clone(&controller_slot);
1221 let scroll_slot = Rc::clone(&scroll_slot);
1222 move || {
1223 let controller_slot = Rc::clone(&controller_slot);
1224 let scroll_slot = Rc::clone(&scroll_slot);
1225 PopupHost(move || {
1226 let controller = remember(TextFieldHandleController::new)
1227 .with(TextFieldHandleController::clone);
1228 *controller_slot.borrow_mut() = Some(controller.clone());
1229 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
1230 *scroll_slot.borrow_mut() = Some(scroll);
1231 let state = state;
1232 let controller = controller.clone();
1233 Column(
1234 Modifier::empty()
1235 .size(Size {
1236 width: 300.0,
1237 height: 150.0,
1238 })
1239 .vertical_scroll(scroll, false),
1240 ColumnSpec::default(),
1241 move || {
1242 Spacer(Size {
1243 width: 300.0,
1244 height: spacer_before,
1245 });
1246 let element = TextFieldElement::new(state, TextStyle::default())
1247 .with_handle_controller(controller.clone());
1248 let field_modifier =
1249 Modifier::from_parts(vec![modifier_element(element)]);
1250 Layout(field_modifier, EmptyMeasurePolicy, || {});
1251 Spacer(Size {
1252 width: 300.0,
1253 height: 400.0,
1254 });
1255 },
1256 );
1257 });
1258 }
1259 };
1260
1261 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1263 if node
1264 .node_data
1265 .modifier_slices()
1266 .text_field_window_origin()
1267 .is_some()
1268 {
1269 return Some(node.rect);
1270 }
1271 node.children.iter().find_map(find_field_rect)
1272 }
1273
1274 fn layout_and_read(
1275 composition: &mut Composition<MemoryApplier>,
1276 key: Key,
1277 content: &mut dyn FnMut(),
1278 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1279 ) -> (Point, f32) {
1280 for _ in 0..16 {
1281 if !composition.should_render() {
1282 break;
1283 }
1284 composition
1285 .reconcile(key, &mut *content)
1286 .expect("reconcile");
1287 }
1288 let root = composition.root().expect("root");
1289 let handle = composition.runtime_handle();
1290 let mut applier = composition.applier_mut();
1291 applier.set_runtime_handle(handle);
1292 let layout = applier
1293 .compute_layout(
1294 root,
1295 cranpose_ui_graphics::Size {
1296 width: 400.0,
1297 height: 600.0,
1298 },
1299 )
1300 .expect("layout");
1301 applier.clear_runtime_handle();
1302 drop(applier);
1303 let _ = HeadlessRenderer::new().render(&layout);
1304 let field_y = find_field_rect(layout.root()).expect("field placed").y;
1305 let node_origin = controller_slot
1306 .borrow()
1307 .as_ref()
1308 .expect("controller")
1309 .metrics()
1310 .expect("metrics published")
1311 .node_origin;
1312 (node_origin, field_y)
1313 }
1314
1315 let key = location_key(file!(), line!(), column!());
1316 composition.render(key, &mut content).expect("render");
1317
1318 let (origin0, field_y0) =
1319 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1320 assert!(
1323 (origin0.y - field_y0).abs() < 0.5,
1324 "published node_origin.y {} must equal the field's placed window-y {}",
1325 origin0.y,
1326 field_y0
1327 );
1328 assert!(
1329 origin0.y >= spacer_before - 0.5,
1330 "field should start at/after the {spacer_before}px leading spacer, got {}",
1331 origin0.y
1332 );
1333
1334 let scroll = *scroll_slot.borrow().as_ref().expect("scroll state");
1337 scroll.scroll_to(50.0);
1338 assert!(
1339 scroll.value() >= 49.5,
1340 "test setup: content must be tall enough to scroll 50px (got {})",
1341 scroll.value()
1342 );
1343 let (origin1, field_y1) =
1344 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1345 assert!(
1346 (origin1.y - field_y1).abs() < 0.5,
1347 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1348 origin1.y,
1349 field_y1
1350 );
1351 assert!(
1352 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1353 "scrolling 50px must shift the published field origin up by 50px: \
1354 before {}, after {} (expected {})",
1355 origin0.y,
1356 origin1.y,
1357 origin0.y - 50.0
1358 );
1359 }
1360
1361 #[test]
1367 fn window_offset_roundtrip_holds_under_scroll_offset() {
1368 let _app_context = crate::render_state::app_context_test_scope();
1369 let text = "hello world";
1370 let style = TextStyle::default();
1371 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1373 let metrics = TextFieldHandleMetrics {
1374 focused: true,
1375 direct_manipulation: true,
1376 node_origin,
1377 padding_left: 4.0,
1378 padding_top: 3.0,
1379 scroll_offset: 0.0,
1380 line_height: 18.0,
1381 glyph_box: (0.0, 18.0),
1382 wrap_width: None,
1383 press: None,
1384 };
1385 for offset in 0..=text.len() {
1386 if !text.is_char_boundary(offset) {
1387 continue;
1388 }
1389 let tip =
1392 handle_tip_window_pos(text, &style, &metrics, offset, LineAffinity::Downstream);
1393 let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1394 assert_eq!(
1395 resolved, offset,
1396 "finger at the tip of offset {offset} must map back to it \
1397 under origin {node_origin:?}, got {resolved}"
1398 );
1399 }
1400 }
1401 }
1402
1403 #[test]
1411 fn shared_wrap_boundary_anchors_by_handle_affinity() {
1412 let _app_context = crate::render_state::app_context_test_scope();
1413 with_test_runtime(|| {
1414 let text = "aaaaaaaaaaaaaaaaaaaaaaaa";
1415 let style = TextStyle::default();
1416 let annotated = crate::text::AnnotatedString::from(text);
1417 let full = crate::text::measure_text(&annotated, &style);
1418 let wrap_width = full.width / 3.0;
1420 let ranges = crate::text::wrapped_line_ranges(
1421 None,
1422 &annotated,
1423 &style,
1424 crate::text::TextLayoutOptions::default(),
1425 Some(wrap_width),
1426 );
1427 assert!(
1428 ranges.len() >= 2,
1429 "test setup: text must wrap, got {ranges:?}"
1430 );
1431 let boundary = ranges[1].start;
1432 assert_eq!(
1433 ranges[0].end, boundary,
1434 "test setup: a mid-word wrap must share its boundary byte, got {ranges:?}"
1435 );
1436
1437 let line_height = 20.0;
1438 let metrics = TextFieldHandleMetrics {
1439 focused: true,
1440 direct_manipulation: true,
1441 node_origin: Point { x: 0.0, y: 0.0 },
1442 padding_left: 0.0,
1443 padding_top: 0.0,
1444 scroll_offset: 0.0,
1445 line_height,
1446 glyph_box: (0.0, line_height),
1447 wrap_width: Some(wrap_width),
1448 press: None,
1449 };
1450
1451 let end_tip =
1452 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Upstream);
1453 assert!(
1454 (end_tip.y - line_height).abs() < 0.5,
1455 "end handle must sit on the UPPER line's bottom ({line_height}), got y={}",
1456 end_tip.y
1457 );
1458 assert!(
1459 end_tip.x > 1.0,
1460 "end handle must sit at the upper line's right edge, got x={}",
1461 end_tip.x
1462 );
1463
1464 let start_tip =
1465 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Downstream);
1466 assert!(
1467 (start_tip.y - 2.0 * line_height).abs() < 0.5,
1468 "start handle must sit on the LOWER line's bottom ({}), got y={}",
1469 2.0 * line_height,
1470 start_tip.y
1471 );
1472 assert!(
1473 start_tip.x.abs() < 0.5,
1474 "start handle must sit at the lower line's left edge, got x={}",
1475 start_tip.x
1476 );
1477
1478 let mut grab = HandleGrabOffset::begin(end_tip.y, end_tip.y);
1484 for finger_y in [
1485 end_tip.y,
1486 end_tip.y + 8.0,
1487 end_tip.y + 32.0,
1488 end_tip.y + 80.0,
1489 ] {
1490 let bias = grab.track(finger_y);
1491 let resolved = window_pos_to_offset(
1492 text,
1493 &style,
1494 &metrics,
1495 Point {
1496 x: end_tip.x,
1497 y: finger_y,
1498 },
1499 bias,
1500 );
1501 let resolved_tip =
1502 handle_tip_window_pos(text, &style, &metrics, resolved, LineAffinity::Upstream);
1503 let target_tip_y =
1504 (finger_y + bias).clamp(line_height, ranges.len() as f32 * line_height);
1505 assert!(
1506 (resolved_tip.y - target_tip_y).abs() <= line_height * 0.5 + 0.5,
1507 "finger y={finger_y}, bias={bias} resolved to offset {resolved} at y={}, expected the nearest visual-line bottom to {}",
1508 resolved_tip.y,
1509 target_tip_y,
1510 );
1511 }
1512 })
1513 }
1514
1515 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1516 use crate::renderer::RenderOp;
1517 scene
1518 .operations()
1519 .iter()
1520 .filter_map(|op| match op {
1521 RenderOp::Text { value, .. } => Some(value.clone()),
1522 _ => None,
1523 })
1524 .collect()
1525 }
1526
1527 fn render_caret_action_menu(
1531 can_paste: bool,
1532 can_undo: bool,
1533 can_redo: bool,
1534 ) -> crate::renderer::RecordedRenderScene {
1535 use crate::layout::LayoutEngine;
1536 use crate::renderer::HeadlessRenderer;
1537 use crate::widgets::PopupHost;
1538 use cranpose_ui_graphics::Size;
1539
1540 let mut composition = Composition::new(MemoryApplier::new());
1541 let key = location_key(file!(), line!(), column!());
1542
1543 let mut content = move || {
1544 PopupHost(move || {
1545 CaretActionMenu(
1546 40.0,
1547 60.0,
1548 true,
1549 can_paste,
1550 can_undo,
1551 can_redo,
1552 || {},
1553 || {},
1554 || {},
1555 || {},
1556 );
1557 });
1558 };
1559
1560 composition.render(key, &mut content).expect("render");
1561 for _ in 0..16 {
1562 if !composition.should_render() {
1563 break;
1564 }
1565 composition.reconcile(key, &mut content).expect("reconcile");
1566 }
1567 let root = composition.root().expect("root");
1568 let handle = composition.runtime_handle();
1569 let mut applier = composition.applier_mut();
1570 applier.set_runtime_handle(handle);
1571 let layout = applier
1572 .compute_layout(
1573 root,
1574 Size {
1575 width: 400.0,
1576 height: 400.0,
1577 },
1578 )
1579 .expect("layout");
1580 applier.clear_runtime_handle();
1581 drop(applier);
1582 HeadlessRenderer::new().render(&layout)
1583 }
1584
1585 #[test]
1589 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1590 let _app_context = crate::render_state::app_context_test_scope();
1591
1592 let all = text_values(&render_caret_action_menu(true, true, true));
1593 for label in ["Paste", "Select all", "Undo", "Redo"] {
1594 assert!(
1595 all.iter().any(|t| t == label),
1596 "caret menu should show {label:?}, got {all:?}"
1597 );
1598 }
1599
1600 let bare = text_values(&render_caret_action_menu(false, false, false));
1602 assert!(
1603 bare.iter().any(|t| t == "Select all"),
1604 "Select all is always available, got {bare:?}"
1605 );
1606 assert!(
1607 !bare
1608 .iter()
1609 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1610 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1611 );
1612 }
1613
1614 #[test]
1615 fn context_menu_shows_for_pointer_selection_on_every_platform() {
1616 let _app_context = crate::render_state::app_context_test_scope();
1617
1618 let touch = text_values(&render_range_menu(true));
1619 assert!(
1620 touch.iter().any(|t| t == "Copy"),
1621 "touch selection should show the Copy menu item, got {touch:?}"
1622 );
1623 assert!(
1624 touch.iter().any(|t| t == "Cut"),
1625 "expected Cut, got {touch:?}"
1626 );
1627 assert!(
1628 touch.iter().any(|t| t == "Select all"),
1629 "expected Select all, got {touch:?}"
1630 );
1631
1632 let mouse = text_values(&render_range_menu(true));
1633 assert!(
1634 mouse.iter().any(|t| t == "Copy"),
1635 "mouse selection must expose the same direct-manipulation menu, got {mouse:?}"
1636 );
1637 let keyboard = text_values(&render_range_menu(false));
1638 assert!(
1639 !keyboard.iter().any(|t| t == "Copy"),
1640 "keyboard-only focus must keep a clean caret, got {keyboard:?}"
1641 );
1642 }
1643
1644 #[test]
1645 fn selection_handles_and_menu_survive_subcomposition() {
1646 let _app_context = crate::render_state::app_context_test_scope();
1652 let scene = render_range_menu_subcomposed(true);
1653
1654 let texts = text_values(&scene);
1655 assert!(
1656 texts.iter().any(|t| t == "Copy"),
1657 "a touch selection inside a subcomposition should show the Copy menu \
1658 item through the host, got {texts:?}"
1659 );
1660 assert!(
1661 texts.iter().any(|t| t == "Select all"),
1662 "expected Select all inside a subcomposition, got {texts:?}"
1663 );
1664 assert_eq!(
1665 image_count(&scene),
1666 2,
1667 "a touch range selection should show two finger teardrop handles in \
1668 the overlay across the subcomposition boundary"
1669 );
1670 }
1671
1672 #[test]
1673 fn selection_handles_and_menu_survive_lazy_column_item() {
1674 let _app_context = crate::render_state::app_context_test_scope();
1681 let scene = render_range_menu_lazy_column(true);
1682
1683 let texts = text_values(&scene);
1684 assert!(
1685 texts.iter().any(|t| t == "Copy"),
1686 "a touch selection inside a LazyColumn item should show the Copy menu \
1687 item through the host, got {texts:?}"
1688 );
1689 assert!(
1690 texts.iter().any(|t| t == "Select all"),
1691 "expected Select all inside a LazyColumn item, got {texts:?}"
1692 );
1693 assert_eq!(
1694 image_count(&scene),
1695 2,
1696 "a touch range selection should show two finger teardrop handles in \
1697 the overlay across the LazyColumn item subcomposition boundary"
1698 );
1699 }
1700
1701 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1702 use crate::renderer::RenderOp;
1703 use cranpose_ui_graphics::DrawPrimitive;
1704 scene
1705 .operations()
1706 .iter()
1707 .filter(|op| {
1708 matches!(
1709 op,
1710 RenderOp::Primitive {
1711 primitive: DrawPrimitive::Image { .. },
1712 ..
1713 }
1714 )
1715 })
1716 .count()
1717 }
1718
1719 #[test]
1720 fn cursor_handle_shows_for_pointer_selection_on_every_platform() {
1721 let _app_context = crate::render_state::app_context_test_scope();
1722 assert_eq!(
1723 image_count(&render_collapsed_handles(true)),
1724 1,
1725 "a touch caret should show one finger cursor handle in the overlay"
1726 );
1727 assert_eq!(
1728 image_count(&render_collapsed_handles(true)),
1729 1,
1730 "a mouse-created caret should expose its draggable handle"
1731 );
1732 assert_eq!(
1733 image_count(&render_collapsed_handles(false)),
1734 0,
1735 "keyboard-only focus should keep a clean caret"
1736 );
1737 }
1738
1739 #[test]
1740 fn basic_text_field_creates_node() {
1741 let _app_context = crate::render_state::app_context_test_scope();
1742 let mut composition = Composition::new(MemoryApplier::new());
1743 let state = TextFieldState::new("Test content");
1744
1745 let result = composition.render(location_key(file!(), line!(), column!()), move || {
1746 BasicTextField(state, Modifier::empty(), TextStyle::default());
1747 });
1748
1749 assert!(result.is_ok());
1750 assert!(composition.root().is_some());
1751 }
1752
1753 #[test]
1754 fn basic_text_field_state_updates() {
1755 let _app_context = crate::render_state::app_context_test_scope();
1756 with_test_runtime(|| {
1757 let state = TextFieldState::new("Hello");
1758 assert_eq!(state.text(), "Hello");
1759
1760 state.edit(|buffer| {
1761 buffer.place_cursor_at_end();
1762 buffer.insert("!");
1763 });
1764
1765 assert_eq!(state.text(), "Hello!");
1766 });
1767 }
1768
1769 #[test]
1778 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1779 use crate::bring_into_view::local_bring_into_view_responder;
1780 use crate::layout::LayoutEngine;
1781 use crate::renderer::HeadlessRenderer;
1782 use crate::widgets::{Box, BoxSpec, PopupHost};
1783 use crate::{LazyColumn, LazyColumnSpec};
1784 use cranpose_core::Key;
1785 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1786 use cranpose_ui_graphics::Size;
1787 use std::cell::RefCell;
1788
1789 let _app_context = crate::render_state::app_context_test_scope();
1790 let mut composition = Composition::new(MemoryApplier::new());
1791 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1792 Rc::new(RefCell::new(None));
1793 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1794
1795 let mut content = {
1798 let responder_slot = Rc::clone(&responder_slot);
1799 let state_slot = Rc::clone(&state_slot);
1800 move || {
1801 let responder_slot = Rc::clone(&responder_slot);
1802 let state_slot = Rc::clone(&state_slot);
1803 PopupHost(move || {
1804 let list_state = remember_lazy_list_state();
1805 *state_slot.borrow_mut() = Some(list_state);
1806 let responder_slot = Rc::clone(&responder_slot);
1807 LazyColumn(
1808 Modifier::empty().size(Size {
1809 width: 300.0,
1810 height: 400.0,
1811 }),
1812 list_state,
1813 LazyColumnSpec::default(),
1814 move |scope| {
1815 let responder_slot = Rc::clone(&responder_slot);
1816 scope.items(
1817 30,
1818 None::<fn(usize) -> u64>,
1819 None::<fn(usize) -> u64>,
1820 move |_index| {
1821 if responder_slot.borrow().is_none() {
1822 if let Some(r) = local_bring_into_view_responder().current()
1823 {
1824 *responder_slot.borrow_mut() = Some(r);
1825 }
1826 }
1827 Box(
1828 Modifier::empty().size(Size {
1829 width: 300.0,
1830 height: 80.0,
1831 }),
1832 BoxSpec::default(),
1833 || {},
1834 );
1835 },
1836 );
1837 },
1838 );
1839 });
1840 }
1841 };
1842
1843 fn run_layout(
1844 composition: &mut Composition<MemoryApplier>,
1845 key: Key,
1846 content: &mut dyn FnMut(),
1847 ) {
1848 for _ in 0..16 {
1849 if !composition.should_render() {
1850 break;
1851 }
1852 composition
1853 .reconcile(key, &mut *content)
1854 .expect("reconcile");
1855 }
1856 let root = composition.root().expect("root");
1857 let handle = composition.runtime_handle();
1858 let mut applier = composition.applier_mut();
1859 applier.set_runtime_handle(handle);
1860 let layout = applier
1861 .compute_layout(
1862 root,
1863 Size {
1864 width: 400.0,
1865 height: 600.0,
1866 },
1867 )
1868 .expect("layout");
1869 applier.clear_runtime_handle();
1870 drop(applier);
1871 let _ = HeadlessRenderer::new().render(&layout);
1872 }
1873
1874 let key = location_key(file!(), line!(), column!());
1875 composition.render(key, &mut content).expect("render");
1876 run_layout(&mut composition, key, &mut content);
1877
1878 let responder = responder_slot
1879 .borrow()
1880 .clone()
1881 .expect("LazyColumn provides a bring-into-view responder to its items");
1882 let list_state = state_slot.borrow().expect("list state captured");
1883 let offset0 = list_state.first_visible_item_scroll_offset();
1884 let index0 = list_state.first_visible_item_index();
1885
1886 responder.bring_into_view(
1889 Rect {
1890 x: 10.0,
1891 y: 100.0,
1892 width: 2.0,
1893 height: 20.0,
1894 },
1895 0.0,
1896 );
1897 run_layout(&mut composition, key, &mut content);
1898 assert_eq!(
1899 list_state.first_visible_item_index(),
1900 index0,
1901 "an already-visible caret must not scroll the list"
1902 );
1903 assert!(
1904 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1905 "an already-visible caret must not scroll the list"
1906 );
1907
1908 responder.bring_into_view(
1911 Rect {
1912 x: 10.0,
1913 y: 360.0,
1914 width: 2.0,
1915 height: 20.0,
1916 },
1917 250.0,
1918 );
1919 run_layout(&mut composition, key, &mut content);
1920 let scrolled_forward = list_state.first_visible_item_index() > index0
1921 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1922 assert!(
1923 scrolled_forward,
1924 "a caret behind the keyboard must scroll the list forward \
1925 (index {} -> {}, offset {:.1} -> {:.1})",
1926 index0,
1927 list_state.first_visible_item_index(),
1928 offset0,
1929 list_state.first_visible_item_scroll_offset(),
1930 );
1931 }
1932}