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