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