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.clone(), 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(
291 state.clone(),
292 options.text_style.clone(),
293 controller.clone(),
294 );
295
296 SelectionHandles(state, options.text_style, controller, options.cursor_color);
303
304 node
305}
306
307fn caret_window_rect(
311 text: &str,
312 style: &TextStyle,
313 metrics: &TextFieldHandleMetrics,
314 offset: usize,
315) -> Rect {
316 let tip = handle_tip_window_pos(text, style, metrics, offset, LineAffinity::Upstream);
319 Rect {
320 x: tip.x,
321 y: tip.y - metrics.glyph_box.1,
322 width: 2.0,
323 height: metrics.glyph_box.1,
324 }
325}
326
327#[composable]
337fn BringCaretIntoView(
338 state: TextFieldState,
339 style: TextStyle,
340 controller: TextFieldHandleController,
341) {
342 let Some(metrics) = controller.metrics() else {
343 return;
344 };
345 let ime_bottom = local_ime_insets().current().bottom;
348 let responder = local_bring_into_view_responder().current();
349
350 let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
351 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
352
353 if !metrics.focused {
354 previous.set(None);
357 return;
358 }
359 let Some(responder) = responder else {
360 return;
361 };
362
363 let text = state.text();
364 let selection = state.selection();
365 let caret = caret_window_rect(&text, &style, &metrics, selection.start);
366
367 let key = (
371 selection.start,
372 selection.end,
373 (ime_bottom * 4.0).round() as i64,
374 );
375 SideEffect(move || {
376 if previous.get() != Some(key) {
377 previous.set(Some(key));
378 responder.bring_into_view(caret, ime_bottom);
379 }
380 });
381}
382
383#[composable]
388fn SelectionHandles(
389 state: TextFieldState,
390 style: TextStyle,
391 controller: TextFieldHandleController,
392 accent: Color,
393) {
394 let selection = state.selection();
395 let current_range = (selection.min(), selection.max());
396
397 let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
401 let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
406 let caret_menu_offset: Rc<Cell<usize>> =
409 remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
410 let previous_range: Rc<Cell<(usize, usize)>> =
411 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
412 {
413 let previous_range = Rc::clone(&previous_range);
414 SideEffect(move || {
415 if previous_range.get() != current_range {
416 previous_range.set(current_range);
417 menu_open.set(true);
418 }
419 });
420 }
421 {
424 let caret_menu_offset = Rc::clone(&caret_menu_offset);
425 let caret_start = selection.start;
426 SideEffect(move || {
427 if caret_menu_open.value()
428 && (!selection.collapsed() || caret_start != caret_menu_offset.get())
429 {
430 caret_menu_open.set(false);
431 }
432 });
433 }
434
435 let Some(metrics) = controller.metrics() else {
436 return;
437 };
438 if !metrics.focused || !metrics.direct_manipulation {
439 return;
440 }
441
442 let text = state.text();
443
444 let press_watcher: Rc<Cell<Option<(u32, u32)>>> =
450 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
451 let press_watcher_ref: Rc<RefCell<Option<Rc<LongPressWatcher>>>> =
452 remember(|| Rc::new(RefCell::new(None))).with(Rc::clone);
453 match metrics.press {
454 Some(press) => {
455 let key = (press.start.x.to_bits(), press.start.y.to_bits());
456 if press_watcher.get() != Some(key) {
457 press_watcher.set(Some(key));
458 let watcher = Rc::new(LongPressWatcher {
459 controller: controller.clone(),
460 state: state.clone(),
461 style: style.clone(),
462 start: press.start,
463 start_nanos: Cell::new(None),
464 registration: RefCell::new(None),
465 frame_clock: cranpose_core::with_current_composer(|composer| {
466 composer.runtime_handle()
467 })
468 .frame_clock(),
469 });
470 watcher.arm();
471 *press_watcher_ref.borrow_mut() = Some(watcher);
472 }
473 }
474 None => {
475 press_watcher.set(None);
476 press_watcher_ref.borrow_mut().take();
477 }
478 }
479
480 let drag_pos: MutableState<Option<Point>> =
484 remember(|| mutableStateOf(None::<Point>)).with(|state| *state);
485 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> =
488 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
489 let last_dragged: Rc<Cell<Option<HandleKind>>> =
494 remember(|| Rc::new(Cell::new(None))).with(Rc::clone);
495 let menu_anchor_range: Rc<Cell<(usize, usize)>> =
496 remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
497 {
498 let last_dragged = Rc::clone(&last_dragged);
499 let menu_anchor_range = Rc::clone(&menu_anchor_range);
500 SideEffect(move || {
501 if menu_anchor_range.get() != current_range {
502 menu_anchor_range.set(current_range);
503 if drag_pos.value().is_none() {
504 last_dragged.set(None);
505 }
506 }
507 });
508 }
509 let cursor_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
517 let start_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
518 let end_tip_y: Rc<Cell<f32>> = remember(|| Rc::new(Cell::new(0.0f32))).with(Rc::clone);
519
520 if selection.collapsed() {
521 let tip = handle_tip_window_pos(
523 &text,
524 &style,
525 &metrics,
526 selection.start,
527 LineAffinity::Upstream,
528 );
529 let on_drag = drag_caret_closure(
530 state.clone(),
531 style.clone(),
532 controller.clone(),
533 Rc::clone(&drag_bias),
534 );
535 let open_caret_menu = {
538 let caret_menu_offset = Rc::clone(&caret_menu_offset);
539 let state = state.clone();
540 move || {
541 caret_menu_offset.set(state.selection().start);
542 caret_menu_open.set(true);
543 }
544 };
545 let on_tap = open_caret_menu.clone();
546 let on_long_press = open_caret_menu;
547 let grab_bias = Rc::clone(&drag_bias);
548 let end_bias = Rc::clone(&drag_bias);
549 cursor_tip_y.set(tip.y);
550 let tip_y = Rc::clone(&cursor_tip_y);
551 SelectionHandle(
552 HandleKind::Cursor,
553 tip,
554 metrics.glyph_box.1,
555 HANDLE_RADIUS,
556 accent,
557 move |pos| {
558 track_handle_grab(&grab_bias, HandleKind::Cursor, tip_y.get(), pos.y);
559 drag_pos.set(Some(pos));
560 on_drag(pos);
561 },
562 move || {
563 drag_pos.set(None);
564 end_bias.set(None);
565 crate::cursor_animation::reset_cursor_blink();
568 },
569 on_long_press,
570 on_tap,
571 );
572
573 if caret_menu_open.value() {
578 let can_paste = clipboard_can_paste();
579 let can_undo = state.can_undo();
580 let can_redo = state.can_redo();
581 let undo_state = state.clone();
582 let redo_state = state.clone();
583 CaretActionMenu(
584 tip.x,
585 tip.y - metrics.glyph_box.1,
586 drag_pos.value().is_none(),
587 can_paste,
588 can_undo,
589 can_redo,
590 move || {
591 clipboard_paste_into_focus();
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_can_paste();
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 clipboard_paste_into_focus();
745 menu_open.set(false);
746 },
747 move || {
748 dispatch_select_all();
749 menu_open.set(false);
750 },
751 );
752 }
753 }
754
755 let loupe_target = drag_pos.value().and_then(|finger| {
762 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
763 let offset = window_pos_to_offset(&text, &style, &metrics, finger, bias);
764 let line_bottom =
767 handle_tip_window_pos(&text, &style, &metrics, offset, LineAffinity::Upstream).y;
768 loupe_target_for_drag(finger, line_bottom, metrics.glyph_box.1)
769 });
770 SelectionLoupe(loupe_target);
771}
772
773fn track_handle_grab(
774 drag_bias: &Cell<Option<HandleGrabOffset>>,
775 kind: HandleKind,
776 handle_tip_y: f32,
777 finger_y: f32,
778) -> f32 {
779 let drifts = kind != HandleKind::SelectionStart;
782 let mut grab = drag_bias
783 .get()
784 .unwrap_or_else(|| HandleGrabOffset::begin_for(handle_tip_y, finger_y, drifts));
785 let bias = grab.track(finger_y);
786 drag_bias.set(Some(grab));
787 bias
788}
789
790fn drag_caret_closure(
794 state: TextFieldState,
795 style: TextStyle,
796 controller: TextFieldHandleController,
797 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
798) -> Rc<dyn Fn(Point)> {
799 Rc::new(move |window_pos: Point| {
800 let Some(metrics) = controller.metrics() else {
801 return;
802 };
803 let text = state.text();
804 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
805 let offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
806 state.set_selection(TextRange::new(offset, offset));
807 crate::cursor_animation::suspend_cursor_blink();
811 crate::request_render_invalidation();
812 })
813}
814
815fn drag_edge_closure(
819 dragged: HandleKind,
820 state: TextFieldState,
821 style: TextStyle,
822 controller: TextFieldHandleController,
823 drag_bias: Rc<Cell<Option<HandleGrabOffset>>>,
824) -> Rc<dyn Fn(Point)> {
825 Rc::new(move |window_pos: Point| {
826 let Some(metrics) = controller.metrics() else {
827 return;
828 };
829 let text = state.text();
830 let bias = drag_bias.get().map_or(0.0, |grab| grab.bias());
831 let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos, bias);
832 let selection = state.selection();
833 let fixed_edge = match dragged {
834 HandleKind::SelectionStart => selection.max(),
835 _ => selection.min(),
836 };
837 let (min, max) =
838 selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
839 state.set_selection(TextRange::new(min, max));
840 crate::request_render_invalidation();
841 })
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn handle_grab_bias_reads_the_live_tip_not_a_snapshot() {
850 let tip_y: Rc<Cell<f32>> = Rc::new(Cell::new(100.0));
856 let drag_bias: Rc<Cell<Option<HandleGrabOffset>>> = Rc::new(Cell::new(None));
857 let grab = {
858 let tip_y = Rc::clone(&tip_y);
859 let drag_bias = Rc::clone(&drag_bias);
860 move |finger_y: f32| {
861 track_handle_grab(&drag_bias, HandleKind::SelectionEnd, tip_y.get(), finger_y)
862 }
863 };
864
865 tip_y.set(148.0);
868 let bias = grab(160.0);
869 assert_eq!(
870 bias,
871 148.0 - 160.0,
872 "the grab bias must anchor on the handle's CURRENT line"
873 );
874 }
875 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
876 use std::sync::Arc;
877
878 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
880 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
881 f()
882 }
883
884 fn render_collapsed_handles(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
888 use crate::layout::LayoutEngine;
889 use crate::renderer::HeadlessRenderer;
890 use crate::widgets::PopupHost;
891 use cranpose_ui_graphics::Size;
892
893 let mut composition = Composition::new(MemoryApplier::new());
894 let key = location_key(file!(), line!(), column!());
895 let state = TextFieldState::new("hello world");
896
897 let mut content = {
898 let state = state.clone();
899 move || {
900 let state = state.clone();
901 PopupHost(move || {
902 let controller = TextFieldHandleController::new();
903 controller.publish(TextFieldHandleMetrics {
904 focused: true,
905 direct_manipulation,
906 node_origin: Point { x: 0.0, y: 10.0 },
907 padding_left: 0.0,
908 padding_top: 0.0,
909 scroll_offset: 0.0,
910 line_height: 18.0,
911 glyph_box: (0.0, 18.0),
912 wrap_width: None,
913 press: None,
914 });
915 SelectionHandles(
916 state.clone(),
917 TextStyle::default(),
918 controller,
919 Color(0.0, 0.478, 1.0, 1.0),
920 );
921 });
922 }
923 };
924
925 composition.render(key, &mut content).expect("render");
926 for _ in 0..16 {
927 if !composition.should_render() {
928 break;
929 }
930 composition.reconcile(key, &mut content).expect("reconcile");
931 }
932 let root = composition.root().expect("root");
933 let handle = composition.runtime_handle();
934 let mut applier = composition.applier_mut();
935 applier.set_runtime_handle(handle);
936 let layout = applier
937 .compute_layout(
938 root,
939 Size {
940 width: 400.0,
941 height: 400.0,
942 },
943 )
944 .expect("layout");
945 applier.clear_runtime_handle();
946 drop(applier);
947 HeadlessRenderer::new().render(&layout)
948 }
949
950 fn render_range_menu(direct_manipulation: bool) -> crate::renderer::RecordedRenderScene {
953 use crate::layout::LayoutEngine;
954 use crate::renderer::HeadlessRenderer;
955 use crate::widgets::PopupHost;
956 use cranpose_ui_graphics::Size;
957
958 let mut composition = Composition::new(MemoryApplier::new());
959 let key = location_key(file!(), line!(), column!());
960 let state = TextFieldState::new("hello world");
961
962 let mut content = {
963 let state = state.clone();
964 move || {
965 let state = state.clone();
966 PopupHost(move || {
967 let controller = TextFieldHandleController::new();
968 if state.selection() != TextRange::new(0, 5) {
969 state.set_selection(TextRange::new(0, 5));
970 }
971 controller.publish(TextFieldHandleMetrics {
972 focused: true,
973 direct_manipulation,
974 node_origin: Point { x: 0.0, y: 40.0 },
975 padding_left: 0.0,
976 padding_top: 0.0,
977 scroll_offset: 0.0,
978 line_height: 18.0,
979 glyph_box: (0.0, 18.0),
980 wrap_width: None,
981 press: None,
982 });
983 SelectionHandles(
984 state.clone(),
985 TextStyle::default(),
986 controller,
987 Color(0.0, 0.478, 1.0, 1.0),
988 );
989 });
990 }
991 };
992
993 composition.render(key, &mut content).expect("render");
994 for _ in 0..16 {
995 if !composition.should_render() {
996 break;
997 }
998 composition.reconcile(key, &mut content).expect("reconcile");
999 }
1000 let root = composition.root().expect("root");
1001 let handle = composition.runtime_handle();
1002 let mut applier = composition.applier_mut();
1003 applier.set_runtime_handle(handle);
1004 let layout = applier
1005 .compute_layout(
1006 root,
1007 Size {
1008 width: 400.0,
1009 height: 400.0,
1010 },
1011 )
1012 .expect("layout");
1013 applier.clear_runtime_handle();
1014 drop(applier);
1015 HeadlessRenderer::new().render(&layout)
1016 }
1017
1018 fn render_range_menu_subcomposed(
1024 direct_manipulation: bool,
1025 ) -> crate::renderer::RecordedRenderScene {
1026 use crate::layout::LayoutEngine;
1027 use crate::renderer::HeadlessRenderer;
1028 use crate::widgets::{BoxWithConstraints, PopupHost};
1029 use cranpose_ui_graphics::Size;
1030
1031 let mut composition = Composition::new(MemoryApplier::new());
1032 let key = location_key(file!(), line!(), column!());
1033 let state = TextFieldState::new("hello world");
1034
1035 let mut content = {
1036 let state = state.clone();
1037 move || {
1038 let state = state.clone();
1039 PopupHost(move || {
1040 let state = state.clone();
1041 BoxWithConstraints(
1042 Modifier::empty().size(Size {
1043 width: 300.0,
1044 height: 300.0,
1045 }),
1046 move |_scope| {
1047 let controller = TextFieldHandleController::new();
1048 if state.selection() != TextRange::new(0, 5) {
1049 state.set_selection(TextRange::new(0, 5));
1050 }
1051 controller.publish(TextFieldHandleMetrics {
1052 focused: true,
1053 direct_manipulation,
1054 node_origin: Point { x: 0.0, y: 40.0 },
1055 padding_left: 0.0,
1056 padding_top: 0.0,
1057 scroll_offset: 0.0,
1058 line_height: 18.0,
1059 glyph_box: (0.0, 18.0),
1060 wrap_width: None,
1061 press: None,
1062 });
1063 SelectionHandles(
1064 state.clone(),
1065 TextStyle::default(),
1066 controller,
1067 Color(0.0, 0.478, 1.0, 1.0),
1068 );
1069 },
1070 );
1071 });
1072 }
1073 };
1074
1075 composition.render(key, &mut content).expect("render");
1076 let root = composition.root().expect("root");
1077 let handle = composition.runtime_handle();
1078 let mut scene = None;
1079 for _ in 0..8 {
1083 for _ in 0..16 {
1084 if !composition.should_render() {
1085 break;
1086 }
1087 composition.reconcile(key, &mut content).expect("reconcile");
1088 }
1089 let mut applier = composition.applier_mut();
1090 applier.set_runtime_handle(handle.clone());
1091 let layout = applier
1092 .compute_layout(
1093 root,
1094 Size {
1095 width: 400.0,
1096 height: 400.0,
1097 },
1098 )
1099 .expect("layout");
1100 applier.clear_runtime_handle();
1101 drop(applier);
1102 scene = Some(HeadlessRenderer::new().render(&layout));
1103 }
1104 scene.expect("scene")
1105 }
1106
1107 fn render_range_menu_lazy_column(
1114 direct_manipulation: bool,
1115 ) -> crate::renderer::RecordedRenderScene {
1116 use crate::layout::LayoutEngine;
1117 use crate::renderer::HeadlessRenderer;
1118 use crate::widgets::PopupHost;
1119 use crate::{LazyColumn, LazyColumnSpec};
1120 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
1121 use cranpose_ui_graphics::Size;
1122
1123 let mut composition = Composition::new(MemoryApplier::new());
1124 let key = location_key(file!(), line!(), column!());
1125 let state = TextFieldState::new("hello world");
1126
1127 let mut content = {
1128 let state = state.clone();
1129 move || {
1130 let state = state.clone();
1131 PopupHost(move || {
1132 let state = state.clone();
1133 let list_state = remember_lazy_list_state();
1134 LazyColumn(
1135 Modifier::empty().size(Size {
1136 width: 300.0,
1137 height: 300.0,
1138 }),
1139 list_state,
1140 LazyColumnSpec::default(),
1141 move |scope| {
1142 let state = state.clone();
1143 scope.items(
1144 1,
1145 None::<fn(usize) -> u64>,
1146 None::<fn(usize) -> u64>,
1147 move |_index| {
1148 let controller = TextFieldHandleController::new();
1149 if state.selection() != TextRange::new(0, 5) {
1150 state.set_selection(TextRange::new(0, 5));
1151 }
1152 controller.publish(TextFieldHandleMetrics {
1153 focused: true,
1154 direct_manipulation,
1155 node_origin: Point { x: 0.0, y: 40.0 },
1156 padding_left: 0.0,
1157 padding_top: 0.0,
1158 scroll_offset: 0.0,
1159 line_height: 18.0,
1160 glyph_box: (0.0, 18.0),
1161 wrap_width: None,
1162 press: None,
1163 });
1164 SelectionHandles(
1165 state.clone(),
1166 TextStyle::default(),
1167 controller,
1168 Color(0.0, 0.478, 1.0, 1.0),
1169 );
1170 },
1171 );
1172 },
1173 );
1174 });
1175 }
1176 };
1177
1178 composition.render(key, &mut content).expect("render");
1179 let root = composition.root().expect("root");
1180 let handle = composition.runtime_handle();
1181 let mut scene = None;
1182 for _ in 0..8 {
1183 for _ in 0..16 {
1184 if !composition.should_render() {
1185 break;
1186 }
1187 composition.reconcile(key, &mut content).expect("reconcile");
1188 }
1189 let mut applier = composition.applier_mut();
1190 applier.set_runtime_handle(handle.clone());
1191 let layout = applier
1192 .compute_layout(
1193 root,
1194 Size {
1195 width: 400.0,
1196 height: 400.0,
1197 },
1198 )
1199 .expect("layout");
1200 applier.clear_runtime_handle();
1201 drop(applier);
1202 scene = Some(HeadlessRenderer::new().render(&layout));
1203 }
1204 scene.expect("scene")
1205 }
1206
1207 #[test]
1216 fn field_window_origin_follows_vertical_scroll() {
1217 use crate::layout::policies::EmptyMeasurePolicy;
1218 use crate::layout::{LayoutBox, LayoutEngine};
1219 use crate::renderer::HeadlessRenderer;
1220 use crate::scroll::ScrollState;
1221 use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
1222 use cranpose_core::{remember, Key};
1223 use cranpose_foundation::modifier_element;
1224 use cranpose_ui_graphics::Size;
1225 use std::cell::RefCell;
1226
1227 let _app_context = crate::render_state::app_context_test_scope();
1228
1229 let mut composition = Composition::new(MemoryApplier::new());
1232 let state = TextFieldState::new("hello world");
1233 let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
1234 Rc::new(RefCell::new(None));
1235 let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1238
1239 let spacer_before = 200.0_f32;
1240 let mut content = {
1241 let state = state.clone();
1242 let controller_slot = Rc::clone(&controller_slot);
1243 let scroll_slot = Rc::clone(&scroll_slot);
1244 move || {
1245 let state = state.clone();
1246 let controller_slot = Rc::clone(&controller_slot);
1247 let scroll_slot = Rc::clone(&scroll_slot);
1248 PopupHost(move || {
1249 let controller = remember(TextFieldHandleController::new)
1250 .with(TextFieldHandleController::clone);
1251 *controller_slot.borrow_mut() = Some(controller.clone());
1252 let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
1253 *scroll_slot.borrow_mut() = Some(scroll.clone());
1254 let state = state.clone();
1255 let controller = controller.clone();
1256 Column(
1257 Modifier::empty()
1258 .size(Size {
1259 width: 300.0,
1260 height: 150.0,
1261 })
1262 .vertical_scroll(scroll.clone(), false),
1263 ColumnSpec::default(),
1264 move || {
1265 Spacer(Size {
1266 width: 300.0,
1267 height: spacer_before,
1268 });
1269 let element =
1270 TextFieldElement::new(state.clone(), TextStyle::default())
1271 .with_handle_controller(controller.clone());
1272 let field_modifier =
1273 Modifier::from_parts(vec![modifier_element(element)]);
1274 Layout(field_modifier, EmptyMeasurePolicy, || {});
1275 Spacer(Size {
1276 width: 300.0,
1277 height: 400.0,
1278 });
1279 },
1280 );
1281 });
1282 }
1283 };
1284
1285 fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
1287 if node
1288 .node_data
1289 .modifier_slices()
1290 .text_field_window_origin()
1291 .is_some()
1292 {
1293 return Some(node.rect);
1294 }
1295 node.children.iter().find_map(find_field_rect)
1296 }
1297
1298 fn layout_and_read(
1299 composition: &mut Composition<MemoryApplier>,
1300 key: Key,
1301 content: &mut dyn FnMut(),
1302 controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
1303 ) -> (Point, f32) {
1304 for _ in 0..16 {
1305 if !composition.should_render() {
1306 break;
1307 }
1308 composition
1309 .reconcile(key, &mut *content)
1310 .expect("reconcile");
1311 }
1312 let root = composition.root().expect("root");
1313 let handle = composition.runtime_handle();
1314 let mut applier = composition.applier_mut();
1315 applier.set_runtime_handle(handle);
1316 let layout = applier
1317 .compute_layout(
1318 root,
1319 cranpose_ui_graphics::Size {
1320 width: 400.0,
1321 height: 600.0,
1322 },
1323 )
1324 .expect("layout");
1325 applier.clear_runtime_handle();
1326 drop(applier);
1327 let _ = HeadlessRenderer::new().render(&layout);
1328 let field_y = find_field_rect(layout.root()).expect("field placed").y;
1329 let node_origin = controller_slot
1330 .borrow()
1331 .as_ref()
1332 .expect("controller")
1333 .metrics()
1334 .expect("metrics published")
1335 .node_origin;
1336 (node_origin, field_y)
1337 }
1338
1339 let key = location_key(file!(), line!(), column!());
1340 composition.render(key, &mut content).expect("render");
1341
1342 let (origin0, field_y0) =
1343 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1344 assert!(
1347 (origin0.y - field_y0).abs() < 0.5,
1348 "published node_origin.y {} must equal the field's placed window-y {}",
1349 origin0.y,
1350 field_y0
1351 );
1352 assert!(
1353 origin0.y >= spacer_before - 0.5,
1354 "field should start at/after the {spacer_before}px leading spacer, got {}",
1355 origin0.y
1356 );
1357
1358 let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
1361 scroll.scroll_to(50.0);
1362 assert!(
1363 scroll.value() >= 49.5,
1364 "test setup: content must be tall enough to scroll 50px (got {})",
1365 scroll.value()
1366 );
1367 let (origin1, field_y1) =
1368 layout_and_read(&mut composition, key, &mut content, &controller_slot);
1369 assert!(
1370 (origin1.y - field_y1).abs() < 0.5,
1371 "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
1372 origin1.y,
1373 field_y1
1374 );
1375 assert!(
1376 (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
1377 "scrolling 50px must shift the published field origin up by 50px: \
1378 before {}, after {} (expected {})",
1379 origin0.y,
1380 origin1.y,
1381 origin0.y - 50.0
1382 );
1383 }
1384
1385 #[test]
1391 fn window_offset_roundtrip_holds_under_scroll_offset() {
1392 let _app_context = crate::render_state::app_context_test_scope();
1393 let text = "hello world";
1394 let style = TextStyle::default();
1395 for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
1397 let metrics = TextFieldHandleMetrics {
1398 focused: true,
1399 direct_manipulation: true,
1400 node_origin,
1401 padding_left: 4.0,
1402 padding_top: 3.0,
1403 scroll_offset: 0.0,
1404 line_height: 18.0,
1405 glyph_box: (0.0, 18.0),
1406 wrap_width: None,
1407 press: None,
1408 };
1409 for offset in 0..=text.len() {
1410 if !text.is_char_boundary(offset) {
1411 continue;
1412 }
1413 let tip =
1416 handle_tip_window_pos(text, &style, &metrics, offset, LineAffinity::Downstream);
1417 let resolved = window_pos_to_offset(text, &style, &metrics, tip, 0.0);
1418 assert_eq!(
1419 resolved, offset,
1420 "finger at the tip of offset {offset} must map back to it \
1421 under origin {node_origin:?}, got {resolved}"
1422 );
1423 }
1424 }
1425 }
1426
1427 #[test]
1435 fn shared_wrap_boundary_anchors_by_handle_affinity() {
1436 let _app_context = crate::render_state::app_context_test_scope();
1437 with_test_runtime(|| {
1438 let text = "aaaaaaaaaaaaaaaaaaaaaaaa";
1439 let style = TextStyle::default();
1440 let annotated = crate::text::AnnotatedString::from(text);
1441 let full = crate::text::measure_text(&annotated, &style);
1442 let wrap_width = full.width / 3.0;
1444 let ranges = crate::text::wrapped_line_ranges(
1445 None,
1446 &annotated,
1447 &style,
1448 crate::text::TextLayoutOptions::default(),
1449 Some(wrap_width),
1450 );
1451 assert!(
1452 ranges.len() >= 2,
1453 "test setup: text must wrap, got {ranges:?}"
1454 );
1455 let boundary = ranges[1].start;
1456 assert_eq!(
1457 ranges[0].end, boundary,
1458 "test setup: a mid-word wrap must share its boundary byte, got {ranges:?}"
1459 );
1460
1461 let line_height = 20.0;
1462 let metrics = TextFieldHandleMetrics {
1463 focused: true,
1464 direct_manipulation: true,
1465 node_origin: Point { x: 0.0, y: 0.0 },
1466 padding_left: 0.0,
1467 padding_top: 0.0,
1468 scroll_offset: 0.0,
1469 line_height,
1470 glyph_box: (0.0, line_height),
1471 wrap_width: Some(wrap_width),
1472 press: None,
1473 };
1474
1475 let end_tip =
1476 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Upstream);
1477 assert!(
1478 (end_tip.y - line_height).abs() < 0.5,
1479 "end handle must sit on the UPPER line's bottom ({line_height}), got y={}",
1480 end_tip.y
1481 );
1482 assert!(
1483 end_tip.x > 1.0,
1484 "end handle must sit at the upper line's right edge, got x={}",
1485 end_tip.x
1486 );
1487
1488 let start_tip =
1489 handle_tip_window_pos(text, &style, &metrics, boundary, LineAffinity::Downstream);
1490 assert!(
1491 (start_tip.y - 2.0 * line_height).abs() < 0.5,
1492 "start handle must sit on the LOWER line's bottom ({}), got y={}",
1493 2.0 * line_height,
1494 start_tip.y
1495 );
1496 assert!(
1497 start_tip.x.abs() < 0.5,
1498 "start handle must sit at the lower line's left edge, got x={}",
1499 start_tip.x
1500 );
1501
1502 let mut grab = HandleGrabOffset::begin(end_tip.y, end_tip.y);
1508 for finger_y in [
1509 end_tip.y,
1510 end_tip.y + 8.0,
1511 end_tip.y + 32.0,
1512 end_tip.y + 80.0,
1513 ] {
1514 let bias = grab.track(finger_y);
1515 let resolved = window_pos_to_offset(
1516 text,
1517 &style,
1518 &metrics,
1519 Point {
1520 x: end_tip.x,
1521 y: finger_y,
1522 },
1523 bias,
1524 );
1525 let resolved_tip =
1526 handle_tip_window_pos(text, &style, &metrics, resolved, LineAffinity::Upstream);
1527 let target_tip_y =
1528 (finger_y + bias).clamp(line_height, ranges.len() as f32 * line_height);
1529 assert!(
1530 (resolved_tip.y - target_tip_y).abs() <= line_height * 0.5 + 0.5,
1531 "finger y={finger_y}, bias={bias} resolved to offset {resolved} at y={}, expected the nearest visual-line bottom to {}",
1532 resolved_tip.y,
1533 target_tip_y,
1534 );
1535 }
1536 })
1537 }
1538
1539 fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
1540 use crate::renderer::RenderOp;
1541 scene
1542 .operations()
1543 .iter()
1544 .filter_map(|op| match op {
1545 RenderOp::Text { value, .. } => Some(value.clone()),
1546 _ => None,
1547 })
1548 .collect()
1549 }
1550
1551 fn render_caret_action_menu(
1555 can_paste: bool,
1556 can_undo: bool,
1557 can_redo: bool,
1558 ) -> crate::renderer::RecordedRenderScene {
1559 use crate::layout::LayoutEngine;
1560 use crate::renderer::HeadlessRenderer;
1561 use crate::widgets::PopupHost;
1562 use cranpose_ui_graphics::Size;
1563
1564 let mut composition = Composition::new(MemoryApplier::new());
1565 let key = location_key(file!(), line!(), column!());
1566
1567 let mut content = move || {
1568 PopupHost(move || {
1569 CaretActionMenu(
1570 40.0,
1571 60.0,
1572 true,
1573 can_paste,
1574 can_undo,
1575 can_redo,
1576 || {},
1577 || {},
1578 || {},
1579 || {},
1580 );
1581 });
1582 };
1583
1584 composition.render(key, &mut content).expect("render");
1585 for _ in 0..16 {
1586 if !composition.should_render() {
1587 break;
1588 }
1589 composition.reconcile(key, &mut content).expect("reconcile");
1590 }
1591 let root = composition.root().expect("root");
1592 let handle = composition.runtime_handle();
1593 let mut applier = composition.applier_mut();
1594 applier.set_runtime_handle(handle);
1595 let layout = applier
1596 .compute_layout(
1597 root,
1598 Size {
1599 width: 400.0,
1600 height: 400.0,
1601 },
1602 )
1603 .expect("layout");
1604 applier.clear_runtime_handle();
1605 drop(applier);
1606 HeadlessRenderer::new().render(&layout)
1607 }
1608
1609 #[test]
1613 fn caret_action_menu_shows_paste_select_all_undo_redo() {
1614 let _app_context = crate::render_state::app_context_test_scope();
1615
1616 let all = text_values(&render_caret_action_menu(true, true, true));
1617 for label in ["Paste", "Select all", "Undo", "Redo"] {
1618 assert!(
1619 all.iter().any(|t| t == label),
1620 "caret menu should show {label:?}, got {all:?}"
1621 );
1622 }
1623
1624 let bare = text_values(&render_caret_action_menu(false, false, false));
1626 assert!(
1627 bare.iter().any(|t| t == "Select all"),
1628 "Select all is always available, got {bare:?}"
1629 );
1630 assert!(
1631 !bare
1632 .iter()
1633 .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
1634 "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
1635 );
1636 }
1637
1638 #[test]
1639 fn context_menu_shows_for_pointer_selection_on_every_platform() {
1640 let _app_context = crate::render_state::app_context_test_scope();
1641
1642 let touch = text_values(&render_range_menu(true));
1643 assert!(
1644 touch.iter().any(|t| t == "Copy"),
1645 "touch selection should show the Copy menu item, got {touch:?}"
1646 );
1647 assert!(
1648 touch.iter().any(|t| t == "Cut"),
1649 "expected Cut, got {touch:?}"
1650 );
1651 assert!(
1652 touch.iter().any(|t| t == "Select all"),
1653 "expected Select all, got {touch:?}"
1654 );
1655
1656 let mouse = text_values(&render_range_menu(true));
1657 assert!(
1658 mouse.iter().any(|t| t == "Copy"),
1659 "mouse selection must expose the same direct-manipulation menu, got {mouse:?}"
1660 );
1661 let keyboard = text_values(&render_range_menu(false));
1662 assert!(
1663 !keyboard.iter().any(|t| t == "Copy"),
1664 "keyboard-only focus must keep a clean caret, got {keyboard:?}"
1665 );
1666 }
1667
1668 #[test]
1669 fn selection_handles_and_menu_survive_subcomposition() {
1670 let _app_context = crate::render_state::app_context_test_scope();
1676 let scene = render_range_menu_subcomposed(true);
1677
1678 let texts = text_values(&scene);
1679 assert!(
1680 texts.iter().any(|t| t == "Copy"),
1681 "a touch selection inside a subcomposition should show the Copy menu \
1682 item through the host, got {texts:?}"
1683 );
1684 assert!(
1685 texts.iter().any(|t| t == "Select all"),
1686 "expected Select all inside a subcomposition, got {texts:?}"
1687 );
1688 assert_eq!(
1689 image_count(&scene),
1690 2,
1691 "a touch range selection should show two finger teardrop handles in \
1692 the overlay across the subcomposition boundary"
1693 );
1694 }
1695
1696 #[test]
1697 fn selection_handles_and_menu_survive_lazy_column_item() {
1698 let _app_context = crate::render_state::app_context_test_scope();
1705 let scene = render_range_menu_lazy_column(true);
1706
1707 let texts = text_values(&scene);
1708 assert!(
1709 texts.iter().any(|t| t == "Copy"),
1710 "a touch selection inside a LazyColumn item should show the Copy menu \
1711 item through the host, got {texts:?}"
1712 );
1713 assert!(
1714 texts.iter().any(|t| t == "Select all"),
1715 "expected Select all inside a LazyColumn item, got {texts:?}"
1716 );
1717 assert_eq!(
1718 image_count(&scene),
1719 2,
1720 "a touch range selection should show two finger teardrop handles in \
1721 the overlay across the LazyColumn item subcomposition boundary"
1722 );
1723 }
1724
1725 fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
1726 use crate::renderer::RenderOp;
1727 use cranpose_ui_graphics::DrawPrimitive;
1728 scene
1729 .operations()
1730 .iter()
1731 .filter(|op| {
1732 matches!(
1733 op,
1734 RenderOp::Primitive {
1735 primitive: DrawPrimitive::Image { .. },
1736 ..
1737 }
1738 )
1739 })
1740 .count()
1741 }
1742
1743 #[test]
1744 fn cursor_handle_shows_for_pointer_selection_on_every_platform() {
1745 let _app_context = crate::render_state::app_context_test_scope();
1746 assert_eq!(
1747 image_count(&render_collapsed_handles(true)),
1748 1,
1749 "a touch caret should show one finger cursor handle in the overlay"
1750 );
1751 assert_eq!(
1752 image_count(&render_collapsed_handles(true)),
1753 1,
1754 "a mouse-created caret should expose its draggable handle"
1755 );
1756 assert_eq!(
1757 image_count(&render_collapsed_handles(false)),
1758 0,
1759 "keyboard-only focus should keep a clean caret"
1760 );
1761 }
1762
1763 #[test]
1764 fn basic_text_field_creates_node() {
1765 let _app_context = crate::render_state::app_context_test_scope();
1766 let mut composition = Composition::new(MemoryApplier::new());
1767 let state = TextFieldState::new("Test content");
1768
1769 let result = composition.render(location_key(file!(), line!(), column!()), {
1770 let state = state.clone();
1771 move || {
1772 BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
1773 }
1774 });
1775
1776 assert!(result.is_ok());
1777 assert!(composition.root().is_some());
1778 }
1779
1780 #[test]
1781 fn basic_text_field_state_updates() {
1782 let _app_context = crate::render_state::app_context_test_scope();
1783 with_test_runtime(|| {
1784 let state = TextFieldState::new("Hello");
1785 assert_eq!(state.text(), "Hello");
1786
1787 state.edit(|buffer| {
1788 buffer.place_cursor_at_end();
1789 buffer.insert("!");
1790 });
1791
1792 assert_eq!(state.text(), "Hello!");
1793 });
1794 }
1795
1796 #[test]
1805 fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
1806 use crate::bring_into_view::local_bring_into_view_responder;
1807 use crate::layout::LayoutEngine;
1808 use crate::renderer::HeadlessRenderer;
1809 use crate::widgets::{Box, BoxSpec, PopupHost};
1810 use crate::{LazyColumn, LazyColumnSpec};
1811 use cranpose_core::Key;
1812 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1813 use cranpose_ui_graphics::Size;
1814 use std::cell::RefCell;
1815
1816 let _app_context = crate::render_state::app_context_test_scope();
1817 let mut composition = Composition::new(MemoryApplier::new());
1818 let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
1819 Rc::new(RefCell::new(None));
1820 let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1821
1822 let mut content = {
1825 let responder_slot = Rc::clone(&responder_slot);
1826 let state_slot = Rc::clone(&state_slot);
1827 move || {
1828 let responder_slot = Rc::clone(&responder_slot);
1829 let state_slot = Rc::clone(&state_slot);
1830 PopupHost(move || {
1831 let list_state = remember_lazy_list_state();
1832 *state_slot.borrow_mut() = Some(list_state);
1833 let responder_slot = Rc::clone(&responder_slot);
1834 LazyColumn(
1835 Modifier::empty().size(Size {
1836 width: 300.0,
1837 height: 400.0,
1838 }),
1839 list_state,
1840 LazyColumnSpec::default(),
1841 move |scope| {
1842 let responder_slot = Rc::clone(&responder_slot);
1843 scope.items(
1844 30,
1845 None::<fn(usize) -> u64>,
1846 None::<fn(usize) -> u64>,
1847 move |_index| {
1848 if responder_slot.borrow().is_none() {
1849 if let Some(r) = local_bring_into_view_responder().current()
1850 {
1851 *responder_slot.borrow_mut() = Some(r);
1852 }
1853 }
1854 Box(
1855 Modifier::empty().size(Size {
1856 width: 300.0,
1857 height: 80.0,
1858 }),
1859 BoxSpec::default(),
1860 || {},
1861 );
1862 },
1863 );
1864 },
1865 );
1866 });
1867 }
1868 };
1869
1870 fn run_layout(
1871 composition: &mut Composition<MemoryApplier>,
1872 key: Key,
1873 content: &mut dyn FnMut(),
1874 ) {
1875 for _ in 0..16 {
1876 if !composition.should_render() {
1877 break;
1878 }
1879 composition
1880 .reconcile(key, &mut *content)
1881 .expect("reconcile");
1882 }
1883 let root = composition.root().expect("root");
1884 let handle = composition.runtime_handle();
1885 let mut applier = composition.applier_mut();
1886 applier.set_runtime_handle(handle);
1887 let layout = applier
1888 .compute_layout(
1889 root,
1890 Size {
1891 width: 400.0,
1892 height: 600.0,
1893 },
1894 )
1895 .expect("layout");
1896 applier.clear_runtime_handle();
1897 drop(applier);
1898 let _ = HeadlessRenderer::new().render(&layout);
1899 }
1900
1901 let key = location_key(file!(), line!(), column!());
1902 composition.render(key, &mut content).expect("render");
1903 run_layout(&mut composition, key, &mut content);
1904
1905 let responder = responder_slot
1906 .borrow()
1907 .clone()
1908 .expect("LazyColumn provides a bring-into-view responder to its items");
1909 let list_state = state_slot.borrow().expect("list state captured");
1910 let offset0 = list_state.first_visible_item_scroll_offset();
1911 let index0 = list_state.first_visible_item_index();
1912
1913 responder.bring_into_view(
1916 Rect {
1917 x: 10.0,
1918 y: 100.0,
1919 width: 2.0,
1920 height: 20.0,
1921 },
1922 0.0,
1923 );
1924 run_layout(&mut composition, key, &mut content);
1925 assert_eq!(
1926 list_state.first_visible_item_index(),
1927 index0,
1928 "an already-visible caret must not scroll the list"
1929 );
1930 assert!(
1931 (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
1932 "an already-visible caret must not scroll the list"
1933 );
1934
1935 responder.bring_into_view(
1938 Rect {
1939 x: 10.0,
1940 y: 360.0,
1941 width: 2.0,
1942 height: 20.0,
1943 },
1944 250.0,
1945 );
1946 run_layout(&mut composition, key, &mut content);
1947 let scrolled_forward = list_state.first_visible_item_index() > index0
1948 || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
1949 assert!(
1950 scrolled_forward,
1951 "a caret behind the keyboard must scroll the list forward \
1952 (index {} -> {}, offset {:.1} -> {:.1})",
1953 index0,
1954 list_state.first_visible_item_index(),
1955 offset0,
1956 list_state.first_visible_item_scroll_offset(),
1957 );
1958 }
1959}