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