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