cranpose-ui 0.1.50

UI primitives for Cranpose
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
//! BasicTextField widget for editable text input.
//!
//! This module provides the `BasicTextField` composable following Jetpack Compose's
//! `BasicTextField` pattern from `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt`.

#![allow(non_snake_case)]

use crate::bring_into_view::local_bring_into_view_responder;
use crate::clipboard_session::{clipboard_read_text, clipboard_write_text};
use crate::composable;
use crate::layout::policies::EmptyMeasurePolicy;
use crate::modifier::Modifier;
use crate::safe_area::local_ime_insets;
use crate::text::{measure_text, AnnotatedString, TextStyle};
use crate::text_field_focus::{dispatch_copy, dispatch_cut, dispatch_paste, dispatch_select_all};
use crate::text_field_modifier_node::{
    TextFieldElement, TextFieldHandleController, TextFieldHandleMetrics,
};
use crate::text_selection::{selection_after_handle_drag, HandleKind, HANDLE_RADIUS};
use crate::widgets::{
    CaretActionMenu, CursorMagnifier, Layout, SelectionHandle, TextSelectionMenu,
};
use cranpose_core::{mutableStateOf, remember, MutableState, NodeId, SideEffect};
use cranpose_foundation::modifier_element;
use cranpose_foundation::text::{TextFieldLineLimits, TextFieldState, TextRange};
use cranpose_ui_graphics::{Color, Point, Rect};
use std::cell::Cell;
use std::rc::Rc;

/// Fill color of the finger selection/cursor handles (Android accent blue).
const HANDLE_COLOR: Color = Color(0.26, 0.52, 0.96, 1.0);

/// Window-space position where a handle's tip should sit for the caret/selection
/// endpoint at byte `offset`: the bottom of that offset's visual line.
fn handle_tip_window_pos(
    text: &str,
    style: &TextStyle,
    metrics: &TextFieldHandleMetrics,
    offset: usize,
) -> Point {
    let offset = offset.min(text.len());
    // Resolve the caret's VISUAL (wrapped) line so the handle tip anchors on the
    // same glyph as the drawn caret (the field wraps long lines; counting only
    // logical `\n` lines would place the handle on the wrong line, far right).
    let (line_index, line_start) = crate::text_field_modifier_node::caret_visual_line_for_offset(
        text,
        style,
        None,
        metrics.wrap_width,
        offset,
    );
    let caret_x = measure_text(&AnnotatedString::from(&text[line_start..offset]), style).width;
    Point {
        x: metrics.node_origin.x + metrics.padding_left + caret_x - metrics.scroll_offset,
        y: metrics.node_origin.y
            + metrics.padding_top
            + (line_index as f32 + 1.0) * metrics.line_height,
    }
}

/// Maps a window-space drag position (finger on the handle bulb) back to the
/// nearest text byte offset in the field.
fn window_pos_to_offset(
    text: &str,
    style: &TextStyle,
    metrics: &TextFieldHandleMetrics,
    window_pos: Point,
) -> usize {
    let local_x = (window_pos.x - metrics.node_origin.x - metrics.padding_left
        + metrics.scroll_offset)
        .max(0.0);
    // The bulb hangs one line below its tip, so bias the sampled y up by a line
    // to select the line the tip points at rather than the one below it.
    let local_y =
        (window_pos.y - metrics.node_origin.y - metrics.padding_top - metrics.line_height).max(0.0);
    crate::text::get_offset_for_position(&AnnotatedString::from(text), style, local_x, local_y)
}
///
/// # When to use
/// Use this when you need an editable text input but want full control over the
/// styling (no built-in borders or labels).
///
/// # Arguments
///
/// * `state` - The observable text field state that holds text content and cursor position.
/// * `modifier` - Modifiers for styling and layout.
/// * `style` - Text styling (color, font size).
///
/// # Example
///
/// ```rust,ignore
/// let text = remember_text_field_state("Initial text");
/// BasicTextField(text, Modifier::padding(8.0), TextStyle::default());
/// ```
#[composable]
pub fn BasicTextField(state: TextFieldState, modifier: Modifier, style: TextStyle) -> NodeId {
    BasicTextFieldWithOptions(
        state,
        modifier,
        BasicTextFieldOptions {
            text_style: style,
            ..BasicTextFieldOptions::default()
        },
    )
}

/// Options for customizing BasicTextField appearance and behavior.
#[derive(Debug, Clone, PartialEq)]
pub struct BasicTextFieldOptions {
    /// Text style
    pub text_style: TextStyle,
    /// Cursor color
    pub cursor_color: Color,
    /// Line limits: SingleLine or MultiLine with optional min/max
    pub line_limits: TextFieldLineLimits,
}

impl Default for BasicTextFieldOptions {
    fn default() -> Self {
        Self {
            text_style: TextStyle::default(),
            cursor_color: Color(0.0, 0.0, 0.0, 1.0), // Black
            line_limits: TextFieldLineLimits::default(),
        }
    }
}

/// Creates an editable text field with custom options.
///
/// This is the full version of `BasicTextField` with all configuration options.
#[composable]
pub fn BasicTextFieldWithOptions(
    state: TextFieldState,
    modifier: Modifier,
    options: BasicTextFieldOptions,
) -> NodeId {
    // Read text + selection to create composition dependencies: the field (and
    // its finger handles) recompose when either changes.
    let _text = state.text();
    let _selection = state.selection();

    // Shared channel through which the field node publishes live handle geometry
    // (focus, touch, on-screen origin, metrics). Remembered so it is stable
    // across recompositions.
    let controller =
        remember(TextFieldHandleController::new).with(TextFieldHandleController::clone);

    // Build the text field element with line limits + the handle controller.
    let text_field_element = TextFieldElement::new(state.clone(), options.text_style.clone())
        .with_cursor_color(options.cursor_color)
        .with_line_limits(options.line_limits)
        .with_handle_controller(controller.clone());

    // Wrap it in a modifier
    let text_field_modifier = modifier_element(text_field_element);
    let final_modifier = Modifier::from_parts(vec![text_field_modifier]);
    let combined_modifier = modifier.then(final_modifier);

    // Use EmptyMeasurePolicy - TextFieldModifierNode handles all measurement
    // This matches Jetpack Compose's BasicTextField architecture
    let node = Layout(
        combined_modifier,
        EmptyMeasurePolicy,
        || {}, // No children
    );

    // Scroll the focused field's caret above the soft keyboard (bug 2): asks the
    // nearest scroll container's `BringIntoViewResponder` to reveal the caret on
    // focus / caret move / keyboard animation. No-op without a scrollable
    // ancestor or when the caret already fits.
    BringCaretIntoView(
        state.clone(),
        options.text_style.clone(),
        controller.clone(),
    );

    // Finger selection handles (touch only): a caret handle for a collapsed
    // selection, start/end teardrops for a range. Rendered in the top-level
    // overlay via `Popup` so they escape the field's clip and hang below the
    // last line. A `PopupHost` at the app root (installed by the shell) is
    // required for them to appear.
    SelectionHandles(state, options.text_style, controller);

    node
}

/// Window-space rect of the field's caret (the cursor line at byte `offset`),
/// derived from the field's published handle [`TextFieldHandleMetrics`]. Its top
/// is the top of the caret's visual line; its height is one line.
fn caret_window_rect(
    text: &str,
    style: &TextStyle,
    metrics: &TextFieldHandleMetrics,
    offset: usize,
) -> Rect {
    // `handle_tip_window_pos` returns the BOTTOM of the caret line (the handle
    // tip); the caret rect starts one line-height above it.
    let tip = handle_tip_window_pos(text, style, metrics, offset);
    Rect {
        x: tip.x,
        y: tip.y - metrics.line_height,
        width: 2.0,
        height: metrics.line_height,
    }
}

/// Consumer half of bug 2: while the field is focused, asks the nearest scroll
/// container (via [`local_bring_into_view_responder`]) to scroll the caret clear
/// of the on-screen keyboard ([`local_ime_insets`]).
///
/// The request is triggered only by focus, caret movement, or a change in the
/// keyboard inset — never by scrolling — so the user is never yanked back while
/// deliberately scrolling the field out of view. The caret rect handed to the
/// responder is always recomputed from the live metrics, so the scroll delta is
/// correct even as the keyboard animates in.
#[composable]
fn BringCaretIntoView(
    state: TextFieldState,
    style: TextStyle,
    controller: TextFieldHandleController,
) {
    let Some(metrics) = controller.metrics() else {
        return;
    };
    // Read the keyboard inset and responder unconditionally so the composable
    // re-runs when either changes even before the field is focused.
    let ime_bottom = local_ime_insets().current().bottom;
    let responder = local_bring_into_view_responder().current();

    let previous: Rc<Cell<Option<(usize, usize, i64)>>> =
        remember(|| Rc::new(Cell::new(None))).with(Rc::clone);

    if !metrics.focused {
        // Reset so the next focus re-requests even if the caret/keyboard match a
        // previous request.
        previous.set(None);
        return;
    }
    let Some(responder) = responder else {
        return;
    };

    let text = state.text();
    let selection = state.selection();
    let caret = caret_window_rect(&text, &style, &metrics, selection.start);

    // Trigger key: caret position + keyboard inset (quantised). Deliberately
    // excludes the field's scroll-driven window origin so scrolling does not
    // re-fire the request.
    let key = (
        selection.start,
        selection.end,
        (ime_bottom * 4.0).round() as i64,
    );
    SideEffect(move || {
        if previous.get() != Some(key) {
            previous.set(Some(key));
            responder.bring_into_view(caret, ime_bottom);
        }
    });
}

/// Emits the finger selection/cursor handles for the field when it is focused
/// and was last touched (never for mouse input, which keeps a clean caret).
#[composable]
fn SelectionHandles(
    state: TextFieldState,
    style: TextStyle,
    controller: TextFieldHandleController,
) {
    let selection = state.selection();
    let current_range = (selection.min(), selection.max());

    // Whether the contextual menu is open. Reopens whenever the selection range
    // changes (a fresh selection), so tapping an action dismisses it until the
    // next selection.
    let menu_open = remember(|| mutableStateOf(true)).with(|state| *state);
    // Whether the collapsed-caret action popup (Paste / Select all / Undo /
    // Redo) is open. Opened by tapping the cursor handle; closed by an action or
    // when the caret leaves the offset it was opened at (typing / tapping
    // elsewhere). Kept out of the range branch so hook order stays stable.
    let caret_menu_open = remember(|| mutableStateOf(false)).with(|state| *state);
    // The caret offset the popup was opened at, so it auto-dismisses once the
    // caret moves away.
    let caret_menu_offset: Rc<Cell<usize>> =
        remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
    let previous_range: Rc<Cell<(usize, usize)>> =
        remember(|| Rc::new(Cell::new(current_range))).with(Rc::clone);
    {
        let previous_range = Rc::clone(&previous_range);
        SideEffect(move || {
            if previous_range.get() != current_range {
                previous_range.set(current_range);
                menu_open.set(true);
            }
        });
    }
    // Auto-dismiss the caret popup once the caret moves off the offset it was
    // opened at (the user typed or tapped elsewhere), matching Android.
    {
        let caret_menu_offset = Rc::clone(&caret_menu_offset);
        let caret_start = selection.start;
        SideEffect(move || {
            if caret_menu_open.value()
                && (!selection.collapsed() || caret_start != caret_menu_offset.get())
            {
                caret_menu_open.set(false);
            }
        });
    }

    let Some(metrics) = controller.metrics() else {
        return;
    };
    if !metrics.focused || !metrics.touch {
        return;
    }

    let text = state.text();

    // Window position of an in-progress handle drag (touch), or `None` when no
    // handle is being dragged. Drives the magnifier loupe (below) so it floats
    // above the finger while the caret/selection edge is being placed.
    let drag_pos: MutableState<Option<Point>> =
        remember(|| mutableStateOf(None::<Point>)).with(|state| *state);

    if selection.collapsed() {
        // Collapsed caret: a single symmetric cursor handle.
        let tip = handle_tip_window_pos(&text, &style, &metrics, selection.start);
        let on_drag = drag_caret_closure(state.clone(), style.clone(), controller.clone());
        // Tapping the cursor handle opens the caret action popup, anchored at
        // the caret's current offset so it auto-dismisses when the caret moves.
        let open_caret_menu = {
            let caret_menu_offset = Rc::clone(&caret_menu_offset);
            let state = state.clone();
            move || {
                caret_menu_offset.set(state.selection().start);
                caret_menu_open.set(true);
            }
        };
        let on_tap = open_caret_menu.clone();
        let on_long_press = open_caret_menu;
        SelectionHandle(
            HandleKind::Cursor,
            tip,
            HANDLE_RADIUS,
            HANDLE_COLOR,
            move |pos| {
                drag_pos.set(Some(pos));
                on_drag(pos);
            },
            move || drag_pos.set(None),
            on_long_press,
            on_tap,
        );

        // The caret action popup (Paste / Select all / Undo / Redo), floating
        // just above the caret.
        if caret_menu_open.value() {
            let caret_anchor = Point {
                x: tip.x,
                y: tip.y - metrics.line_height,
            };
            let can_paste = clipboard_read_text().is_some();
            let can_undo = state.can_undo();
            let can_redo = state.can_redo();
            let undo_state = state.clone();
            let redo_state = state.clone();
            CaretActionMenu(
                caret_anchor,
                can_paste,
                can_undo,
                can_redo,
                move || {
                    if let Some(text) = clipboard_read_text() {
                        dispatch_paste(&text);
                    }
                    caret_menu_open.set(false);
                },
                move || {
                    dispatch_select_all();
                    caret_menu_open.set(false);
                },
                move || {
                    undo_state.undo();
                    crate::request_render_invalidation();
                    caret_menu_open.set(false);
                },
                move || {
                    redo_state.redo();
                    crate::request_render_invalidation();
                    caret_menu_open.set(false);
                },
            );
        }
    } else {
        // Range selection: start (leftmost) and end (rightmost) teardrops.
        let start = selection.min();
        let end = selection.max();
        let start_tip = handle_tip_window_pos(&text, &style, &metrics, start);
        let end_tip = handle_tip_window_pos(&text, &style, &metrics, end);

        let on_drag_start = drag_edge_closure(
            HandleKind::SelectionStart,
            state.clone(),
            style.clone(),
            controller.clone(),
        );
        SelectionHandle(
            HandleKind::SelectionStart,
            start_tip,
            HANDLE_RADIUS,
            HANDLE_COLOR,
            move |pos| {
                drag_pos.set(Some(pos));
                on_drag_start(pos);
            },
            move || drag_pos.set(None),
            // Long-pressing a handle re-opens the contextual menu even when the
            // selection range has not changed (e.g. after it was dismissed by a
            // previous action), so the text actions stay reachable.
            move || menu_open.set(true),
            // A tap on a selection-edge handle also re-opens the menu.
            move || menu_open.set(true),
        );

        let on_drag_end = drag_edge_closure(
            HandleKind::SelectionEnd,
            state.clone(),
            style.clone(),
            controller.clone(),
        );
        SelectionHandle(
            HandleKind::SelectionEnd,
            end_tip,
            HANDLE_RADIUS,
            HANDLE_COLOR,
            move |pos| {
                drag_pos.set(Some(pos));
                on_drag_end(pos);
            },
            move || drag_pos.set(None),
            move || menu_open.set(true),
            move || menu_open.set(true),
        );

        // Contextual menu (Copy / Cut / Paste / Select all) floating above the
        // selection. Actions run against the focused field and dismiss the menu.
        if menu_open.value() {
            // Top-center of the selection's first line.
            let menu_anchor = Point {
                x: (start_tip.x + end_tip.x) * 0.5,
                y: start_tip.y - metrics.line_height,
            };
            let can_paste = clipboard_read_text().is_some();
            TextSelectionMenu(
                menu_anchor,
                can_paste,
                move || {
                    if let Some(text) = dispatch_copy() {
                        clipboard_write_text(&text);
                    }
                    menu_open.set(false);
                },
                move || {
                    if let Some(text) = dispatch_cut() {
                        clipboard_write_text(&text);
                    }
                    menu_open.set(false);
                },
                move || {
                    if let Some(text) = clipboard_read_text() {
                        dispatch_paste(&text);
                    }
                    menu_open.set(false);
                },
                move || {
                    dispatch_select_all();
                    menu_open.set(false);
                },
            );
        }
    }

    // Magnifier loupe: while a handle is being dragged, float a magnified slice
    // of the text above the finger so it does not obscure the caret it is
    // placing. Touch-only (a drag position is only ever set from a finger
    // handle drag). The magnified offset is resolved from the finger's window
    // position through the SAME live composited metrics the handles use, so the
    // loupe stays centered on the character under the finger even mid-scroll.
    if let Some(finger) = drag_pos.value() {
        let offset = window_pos_to_offset(&text, &style, &metrics, finger);
        CursorMagnifier(text.clone(), style.clone(), metrics, offset, finger);
    }
}

/// Builds the drag handler for the collapsed cursor handle: moves the caret to
/// the dragged position.
fn drag_caret_closure(
    state: TextFieldState,
    style: TextStyle,
    controller: TextFieldHandleController,
) -> Rc<dyn Fn(Point)> {
    Rc::new(move |window_pos: Point| {
        let Some(metrics) = controller.metrics() else {
            return;
        };
        let text = state.text();
        let offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
        state.set_selection(TextRange::new(offset, offset));
        crate::request_render_invalidation();
    })
}

/// Builds the drag handler for a selection start/end teardrop: extends the
/// selection to the dragged position while keeping the opposite edge fixed and
/// never letting the edges cross.
fn drag_edge_closure(
    dragged: HandleKind,
    state: TextFieldState,
    style: TextStyle,
    controller: TextFieldHandleController,
) -> Rc<dyn Fn(Point)> {
    Rc::new(move |window_pos: Point| {
        let Some(metrics) = controller.metrics() else {
            return;
        };
        let text = state.text();
        let dragged_offset = window_pos_to_offset(&text, &style, &metrics, window_pos);
        let selection = state.selection();
        let fixed_edge = match dragged {
            HandleKind::SelectionStart => selection.max(),
            _ => selection.min(),
        };
        let (min, max) =
            selection_after_handle_drag(dragged, fixed_edge, dragged_offset, text.len());
        state.set_selection(TextRange::new(min, max));
        crate::request_render_invalidation();
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
    use std::sync::Arc;

    /// Sets up a test runtime and keeps it alive for the duration of the test.
    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
        f()
    }

    /// Composes just the finger handles for a collapsed caret with the given
    /// published metrics, and returns the rendered scene. The teardrop
    /// rasterizes to an image primitive, so counting images counts handles.
    fn render_collapsed_handles(touch: bool) -> crate::renderer::RecordedRenderScene {
        use crate::layout::LayoutEngine;
        use crate::renderer::HeadlessRenderer;
        use crate::widgets::PopupHost;
        use cranpose_ui_graphics::Size;

        let mut composition = Composition::new(MemoryApplier::new());
        let key = location_key(file!(), line!(), column!());
        let state = TextFieldState::new("hello world");

        let mut content = {
            let state = state.clone();
            move || {
                let state = state.clone();
                PopupHost(move || {
                    let controller = TextFieldHandleController::new();
                    controller.publish(TextFieldHandleMetrics {
                        focused: true,
                        touch,
                        node_origin: Point { x: 0.0, y: 10.0 },
                        padding_left: 0.0,
                        padding_top: 0.0,
                        scroll_offset: 0.0,
                        line_height: 18.0,
                        wrap_width: None,
                    });
                    SelectionHandles(state.clone(), TextStyle::default(), controller);
                });
            }
        };

        composition.render(key, &mut content).expect("render");
        for _ in 0..16 {
            if !composition.should_render() {
                break;
            }
            composition.reconcile(key, &mut content).expect("reconcile");
        }
        let root = composition.root().expect("root");
        let handle = composition.runtime_handle();
        let mut applier = composition.applier_mut();
        applier.set_runtime_handle(handle);
        let layout = applier
            .compute_layout(
                root,
                Size {
                    width: 400.0,
                    height: 400.0,
                },
            )
            .expect("layout");
        applier.clear_runtime_handle();
        drop(applier);
        HeadlessRenderer::new().render(&layout)
    }

    /// Composes the handles + contextual menu for a range selection with the
    /// given metrics, returning the rendered scene.
    fn render_range_menu(touch: bool) -> crate::renderer::RecordedRenderScene {
        use crate::layout::LayoutEngine;
        use crate::renderer::HeadlessRenderer;
        use crate::widgets::PopupHost;
        use cranpose_ui_graphics::Size;

        let mut composition = Composition::new(MemoryApplier::new());
        let key = location_key(file!(), line!(), column!());
        let state = TextFieldState::new("hello world");

        let mut content = {
            let state = state.clone();
            move || {
                let state = state.clone();
                PopupHost(move || {
                    let controller = TextFieldHandleController::new();
                    if state.selection() != TextRange::new(0, 5) {
                        state.set_selection(TextRange::new(0, 5));
                    }
                    controller.publish(TextFieldHandleMetrics {
                        focused: true,
                        touch,
                        node_origin: Point { x: 0.0, y: 40.0 },
                        padding_left: 0.0,
                        padding_top: 0.0,
                        scroll_offset: 0.0,
                        line_height: 18.0,
                        wrap_width: None,
                    });
                    SelectionHandles(state.clone(), TextStyle::default(), controller);
                });
            }
        };

        composition.render(key, &mut content).expect("render");
        for _ in 0..16 {
            if !composition.should_render() {
                break;
            }
            composition.reconcile(key, &mut content).expect("reconcile");
        }
        let root = composition.root().expect("root");
        let handle = composition.runtime_handle();
        let mut applier = composition.applier_mut();
        applier.set_runtime_handle(handle);
        let layout = applier
            .compute_layout(
                root,
                Size {
                    width: 400.0,
                    height: 400.0,
                },
            )
            .expect("layout");
        applier.clear_runtime_handle();
        drop(applier);
        HeadlessRenderer::new().render(&layout)
    }

    /// Like [`render_range_menu`], but the metrics + `SelectionHandles` are
    /// composed inside a `BoxWithConstraints` (which subcomposes its content off
    /// the measure pass), mirroring a real app where text fields live inside
    /// `BoxWithConstraints`/`LazyColumn`. The overlay `Popup`s must still reach
    /// the enclosing `PopupHost` across the subcomposition boundary.
    fn render_range_menu_subcomposed(touch: bool) -> crate::renderer::RecordedRenderScene {
        use crate::layout::LayoutEngine;
        use crate::renderer::HeadlessRenderer;
        use crate::widgets::{BoxWithConstraints, PopupHost};
        use cranpose_ui_graphics::Size;

        let mut composition = Composition::new(MemoryApplier::new());
        let key = location_key(file!(), line!(), column!());
        let state = TextFieldState::new("hello world");

        let mut content = {
            let state = state.clone();
            move || {
                let state = state.clone();
                PopupHost(move || {
                    let state = state.clone();
                    BoxWithConstraints(
                        Modifier::empty().size(Size {
                            width: 300.0,
                            height: 300.0,
                        }),
                        move |_scope| {
                            let controller = TextFieldHandleController::new();
                            if state.selection() != TextRange::new(0, 5) {
                                state.set_selection(TextRange::new(0, 5));
                            }
                            controller.publish(TextFieldHandleMetrics {
                                focused: true,
                                touch,
                                node_origin: Point { x: 0.0, y: 40.0 },
                                padding_left: 0.0,
                                padding_top: 0.0,
                                scroll_offset: 0.0,
                                line_height: 18.0,
                                wrap_width: None,
                            });
                            SelectionHandles(state.clone(), TextStyle::default(), controller);
                        },
                    );
                });
            }
        };

        composition.render(key, &mut content).expect("render");
        let root = composition.root().expect("root");
        let handle = composition.runtime_handle();
        let mut scene = None;
        // The Popups register during the measure-pass subcomposition, so a
        // follow-up frame (reconcile + layout) is needed for the host to render
        // them. Alternate the two a few times, as real frames do.
        for _ in 0..8 {
            for _ in 0..16 {
                if !composition.should_render() {
                    break;
                }
                composition.reconcile(key, &mut content).expect("reconcile");
            }
            let mut applier = composition.applier_mut();
            applier.set_runtime_handle(handle.clone());
            let layout = applier
                .compute_layout(
                    root,
                    Size {
                        width: 400.0,
                        height: 400.0,
                    },
                )
                .expect("layout");
            applier.clear_runtime_handle();
            drop(applier);
            scene = Some(HeadlessRenderer::new().render(&layout));
        }
        scene.expect("scene")
    }

    /// Like [`render_range_menu_subcomposed`], but the field lives inside a
    /// `LazyColumn` *item* — the exact shape of the reported device bug (a
    /// multi-line field in a lazy list). The item is subcomposed off the
    /// measure pass through `LazyColumn`'s own `SubcomposeLayoutNode`, so the
    /// overlay `Popup`s (handles + menu) only reach the enclosing `PopupHost`
    /// once the item subcomposition inherits the call-site composition locals.
    fn render_range_menu_lazy_column(touch: bool) -> crate::renderer::RecordedRenderScene {
        use crate::layout::LayoutEngine;
        use crate::renderer::HeadlessRenderer;
        use crate::widgets::PopupHost;
        use crate::{LazyColumn, LazyColumnSpec};
        use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope};
        use cranpose_ui_graphics::Size;

        let mut composition = Composition::new(MemoryApplier::new());
        let key = location_key(file!(), line!(), column!());
        let state = TextFieldState::new("hello world");

        let mut content = {
            let state = state.clone();
            move || {
                let state = state.clone();
                PopupHost(move || {
                    let state = state.clone();
                    let list_state = remember_lazy_list_state();
                    LazyColumn(
                        Modifier::empty().size(Size {
                            width: 300.0,
                            height: 300.0,
                        }),
                        list_state,
                        LazyColumnSpec::default(),
                        move |scope| {
                            let state = state.clone();
                            scope.items(
                                1,
                                None::<fn(usize) -> u64>,
                                None::<fn(usize) -> u64>,
                                move |_index| {
                                    let controller = TextFieldHandleController::new();
                                    if state.selection() != TextRange::new(0, 5) {
                                        state.set_selection(TextRange::new(0, 5));
                                    }
                                    controller.publish(TextFieldHandleMetrics {
                                        focused: true,
                                        touch,
                                        node_origin: Point { x: 0.0, y: 40.0 },
                                        padding_left: 0.0,
                                        padding_top: 0.0,
                                        scroll_offset: 0.0,
                                        line_height: 18.0,
                                        wrap_width: None,
                                    });
                                    SelectionHandles(
                                        state.clone(),
                                        TextStyle::default(),
                                        controller,
                                    );
                                },
                            );
                        },
                    );
                });
            }
        };

        composition.render(key, &mut content).expect("render");
        let root = composition.root().expect("root");
        let handle = composition.runtime_handle();
        let mut scene = None;
        for _ in 0..8 {
            for _ in 0..16 {
                if !composition.should_render() {
                    break;
                }
                composition.reconcile(key, &mut content).expect("reconcile");
            }
            let mut applier = composition.applier_mut();
            applier.set_runtime_handle(handle.clone());
            let layout = applier
                .compute_layout(
                    root,
                    Size {
                        width: 400.0,
                        height: 400.0,
                    },
                )
                .expect("layout");
            applier.clear_runtime_handle();
            drop(applier);
            scene = Some(HeadlessRenderer::new().render(&layout));
        }
        scene.expect("scene")
    }

    /// Regression for the core device bug: a `BasicTextField` inside a
    /// vertically-scrolled container must publish its TRUE composited window
    /// origin so the finger selection/cursor handles anchor on the glyphs and
    /// follow the list as it scrolls. Before the fix the field origin was only
    /// ever sampled from the last pointer event, so it went stale on scroll —
    /// the handles rendered offset from the text, did not move while scrolling,
    /// and the window→offset inverse mapping (drag → text offset, and the
    /// handle-grab hit region) pointed at the wrong character.
    #[test]
    fn field_window_origin_follows_vertical_scroll() {
        use crate::layout::policies::EmptyMeasurePolicy;
        use crate::layout::{LayoutBox, LayoutEngine};
        use crate::renderer::HeadlessRenderer;
        use crate::scroll::ScrollState;
        use crate::widgets::{Column, ColumnSpec, Layout, PopupHost, Spacer};
        use cranpose_core::{remember, Key};
        use cranpose_foundation::modifier_element;
        use cranpose_ui_graphics::Size;
        use std::cell::RefCell;

        let _app_context = crate::render_state::app_context_test_scope();

        // `Composition::new` installs the runtime `TextFieldState`/`ScrollState`
        // need, so build it before allocating any state.
        let mut composition = Composition::new(MemoryApplier::new());
        let state = TextFieldState::new("hello world");
        let controller_slot: Rc<RefCell<Option<TextFieldHandleController>>> =
            Rc::new(RefCell::new(None));
        // `ScrollState` allocates a `MutableState`, so it must be created inside
        // the composition's runtime; publish it out through a slot to drive it.
        let scroll_slot: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));

        let spacer_before = 200.0_f32;
        let mut content = {
            let state = state.clone();
            let controller_slot = Rc::clone(&controller_slot);
            let scroll_slot = Rc::clone(&scroll_slot);
            move || {
                let state = state.clone();
                let controller_slot = Rc::clone(&controller_slot);
                let scroll_slot = Rc::clone(&scroll_slot);
                PopupHost(move || {
                    let controller = remember(TextFieldHandleController::new)
                        .with(TextFieldHandleController::clone);
                    *controller_slot.borrow_mut() = Some(controller.clone());
                    let scroll = remember(|| ScrollState::new(0.0)).with(ScrollState::clone);
                    *scroll_slot.borrow_mut() = Some(scroll.clone());
                    let state = state.clone();
                    let controller = controller.clone();
                    Column(
                        Modifier::empty()
                            .size(Size {
                                width: 300.0,
                                height: 150.0,
                            })
                            .vertical_scroll(scroll.clone(), false),
                        ColumnSpec::default(),
                        move || {
                            Spacer(Size {
                                width: 300.0,
                                height: spacer_before,
                            });
                            let element =
                                TextFieldElement::new(state.clone(), TextStyle::default())
                                    .with_handle_controller(controller.clone());
                            let field_modifier =
                                Modifier::from_parts(vec![modifier_element(element)]);
                            Layout(field_modifier, EmptyMeasurePolicy, || {});
                            Spacer(Size {
                                width: 300.0,
                                height: 400.0,
                            });
                        },
                    );
                });
            }
        };

        // The field node is the only one carrying a window-origin sink.
        fn find_field_rect(node: &LayoutBox) -> Option<cranpose_ui_graphics::Rect> {
            if node
                .node_data
                .modifier_slices()
                .text_field_window_origin()
                .is_some()
            {
                return Some(node.rect);
            }
            node.children.iter().find_map(find_field_rect)
        }

        fn layout_and_read(
            composition: &mut Composition<MemoryApplier>,
            key: Key,
            content: &mut dyn FnMut(),
            controller_slot: &Rc<RefCell<Option<TextFieldHandleController>>>,
        ) -> (Point, f32) {
            for _ in 0..16 {
                if !composition.should_render() {
                    break;
                }
                composition
                    .reconcile(key, &mut *content)
                    .expect("reconcile");
            }
            let root = composition.root().expect("root");
            let handle = composition.runtime_handle();
            let mut applier = composition.applier_mut();
            applier.set_runtime_handle(handle);
            let layout = applier
                .compute_layout(
                    root,
                    cranpose_ui_graphics::Size {
                        width: 400.0,
                        height: 600.0,
                    },
                )
                .expect("layout");
            applier.clear_runtime_handle();
            drop(applier);
            let _ = HeadlessRenderer::new().render(&layout);
            let field_y = find_field_rect(layout.root()).expect("field placed").y;
            let node_origin = controller_slot
                .borrow()
                .as_ref()
                .expect("controller")
                .metrics()
                .expect("metrics published")
                .node_origin;
            (node_origin, field_y)
        }

        let key = location_key(file!(), line!(), column!());
        composition.render(key, &mut content).expect("render");

        let (origin0, field_y0) =
            layout_and_read(&mut composition, key, &mut content, &controller_slot);
        // The published origin must equal the field's real placed window rect
        // (not a stale pointer sample), and start below the leading spacer.
        assert!(
            (origin0.y - field_y0).abs() < 0.5,
            "published node_origin.y {} must equal the field's placed window-y {}",
            origin0.y,
            field_y0
        );
        assert!(
            origin0.y >= spacer_before - 0.5,
            "field should start at/after the {spacer_before}px leading spacer, got {}",
            origin0.y
        );

        // Scroll the list down by 50px; the field must move up by exactly 50px
        // and the published origin must track it live.
        let scroll = scroll_slot.borrow().as_ref().expect("scroll state").clone();
        scroll.scroll_to(50.0);
        assert!(
            scroll.value() >= 49.5,
            "test setup: content must be tall enough to scroll 50px (got {})",
            scroll.value()
        );
        let (origin1, field_y1) =
            layout_and_read(&mut composition, key, &mut content, &controller_slot);
        assert!(
            (origin1.y - field_y1).abs() < 0.5,
            "after scroll, node_origin.y {} must still equal the field's placed window-y {}",
            origin1.y,
            field_y1
        );
        assert!(
            (origin1.y - (origin0.y - 50.0)).abs() < 0.5,
            "scrolling 50px must shift the published field origin up by 50px: \
             before {}, after {} (expected {})",
            origin0.y,
            origin1.y,
            origin0.y - 50.0
        );
    }

    /// After a scroll, the window→offset inverse mapping must still resolve the
    /// character the finger is over, because it reads the same live composited
    /// origin the handles are placed at. Uses the published post-scroll metrics
    /// to round-trip every caret offset through
    /// `handle_tip_window_pos` → `window_pos_to_offset`.
    #[test]
    fn window_offset_roundtrip_holds_under_scroll_offset() {
        let _app_context = crate::render_state::app_context_test_scope();
        let text = "hello world";
        let style = TextStyle::default();
        // Two different composited origins (as if the list scrolled between them).
        for node_origin in [Point { x: 12.0, y: 240.0 }, Point { x: 12.0, y: 190.0 }] {
            let metrics = TextFieldHandleMetrics {
                focused: true,
                touch: true,
                node_origin,
                padding_left: 4.0,
                padding_top: 3.0,
                scroll_offset: 0.0,
                line_height: 18.0,
                wrap_width: None,
            };
            for offset in 0..=text.len() {
                if !text.is_char_boundary(offset) {
                    continue;
                }
                let tip = handle_tip_window_pos(text, &style, &metrics, offset);
                let resolved = window_pos_to_offset(text, &style, &metrics, tip);
                assert_eq!(
                    resolved, offset,
                    "finger at the tip of offset {offset} must map back to it \
                     under origin {node_origin:?}, got {resolved}"
                );
            }
        }
    }

    fn text_values(scene: &crate::renderer::RecordedRenderScene) -> Vec<String> {
        use crate::renderer::RenderOp;
        scene
            .operations()
            .iter()
            .filter_map(|op| match op {
                RenderOp::Text { value, .. } => Some(value.clone()),
                _ => None,
            })
            .collect()
    }

    /// Composes the caret action popup directly inside a `PopupHost` (as the
    /// cursor-handle tap does once `caret_menu_open` is set) and returns the
    /// rendered scene, so the item labels can be asserted.
    fn render_caret_action_menu(
        can_paste: bool,
        can_undo: bool,
        can_redo: bool,
    ) -> crate::renderer::RecordedRenderScene {
        use crate::layout::LayoutEngine;
        use crate::renderer::HeadlessRenderer;
        use crate::widgets::PopupHost;
        use cranpose_ui_graphics::Size;

        let mut composition = Composition::new(MemoryApplier::new());
        let key = location_key(file!(), line!(), column!());

        let mut content = move || {
            PopupHost(move || {
                CaretActionMenu(
                    Point { x: 40.0, y: 60.0 },
                    can_paste,
                    can_undo,
                    can_redo,
                    || {},
                    || {},
                    || {},
                    || {},
                );
            });
        };

        composition.render(key, &mut content).expect("render");
        for _ in 0..16 {
            if !composition.should_render() {
                break;
            }
            composition.reconcile(key, &mut content).expect("reconcile");
        }
        let root = composition.root().expect("root");
        let handle = composition.runtime_handle();
        let mut applier = composition.applier_mut();
        applier.set_runtime_handle(handle);
        let layout = applier
            .compute_layout(
                root,
                Size {
                    width: 400.0,
                    height: 400.0,
                },
            )
            .expect("layout");
        applier.clear_runtime_handle();
        drop(applier);
        HeadlessRenderer::new().render(&layout)
    }

    /// Bug (b): the caret action popup offers Paste / Select all / Undo / Redo.
    /// Paste is hidden when the clipboard is empty, and Undo/Redo when the
    /// field's history has nothing to undo/redo.
    #[test]
    fn caret_action_menu_shows_paste_select_all_undo_redo() {
        let _app_context = crate::render_state::app_context_test_scope();

        let all = text_values(&render_caret_action_menu(true, true, true));
        for label in ["Paste", "Select all", "Undo", "Redo"] {
            assert!(
                all.iter().any(|t| t == label),
                "caret menu should show {label:?}, got {all:?}"
            );
        }

        // Nothing on the clipboard and an empty history: only Select all.
        let bare = text_values(&render_caret_action_menu(false, false, false));
        assert!(
            bare.iter().any(|t| t == "Select all"),
            "Select all is always available, got {bare:?}"
        );
        assert!(
            !bare
                .iter()
                .any(|t| t == "Paste" || t == "Undo" || t == "Redo"),
            "Paste/Undo/Redo must be hidden when unavailable, got {bare:?}"
        );
    }

    #[test]
    fn context_menu_shows_for_touch_selection_only() {
        let _app_context = crate::render_state::app_context_test_scope();

        let touch = text_values(&render_range_menu(true));
        assert!(
            touch.iter().any(|t| t == "Copy"),
            "touch selection should show the Copy menu item, got {touch:?}"
        );
        assert!(
            touch.iter().any(|t| t == "Cut"),
            "expected Cut, got {touch:?}"
        );
        assert!(
            touch.iter().any(|t| t == "Select all"),
            "expected Select all, got {touch:?}"
        );

        let mouse = text_values(&render_range_menu(false));
        assert!(
            !mouse.iter().any(|t| t == "Copy"),
            "mouse selection must not show the finger contextual menu, got {mouse:?}"
        );
    }

    #[test]
    fn selection_handles_and_menu_survive_subcomposition() {
        // Regression: the selection handles and the contextual menu (all drawn
        // through `Popup`) must reach the enclosing `PopupHost` even when the
        // text field lives inside a `BoxWithConstraints`/`LazyColumn`, which
        // subcomposes its content off the measure pass. Both the two teardrop
        // handles and the menu items are expected.
        let _app_context = crate::render_state::app_context_test_scope();
        let scene = render_range_menu_subcomposed(true);

        let texts = text_values(&scene);
        assert!(
            texts.iter().any(|t| t == "Copy"),
            "a touch selection inside a subcomposition should show the Copy menu \
             item through the host, got {texts:?}"
        );
        assert!(
            texts.iter().any(|t| t == "Select all"),
            "expected Select all inside a subcomposition, got {texts:?}"
        );
        assert_eq!(
            image_count(&scene),
            2,
            "a touch range selection should show two finger teardrop handles in \
             the overlay across the subcomposition boundary"
        );
    }

    #[test]
    fn selection_handles_and_menu_survive_lazy_column_item() {
        // Regression for the reported device bug: a text field inside a
        // `LazyColumn` item shows neither its selection handles nor its context
        // menu, because the item is subcomposed off the list's measure pass and
        // the overlay `Popup`s lose the enclosing `PopupHost` registry across
        // that boundary. After capturing the call-site locals in `LazyColumn`
        // the handles + menu reach the host, just like a top-level field.
        let _app_context = crate::render_state::app_context_test_scope();
        let scene = render_range_menu_lazy_column(true);

        let texts = text_values(&scene);
        assert!(
            texts.iter().any(|t| t == "Copy"),
            "a touch selection inside a LazyColumn item should show the Copy menu \
             item through the host, got {texts:?}"
        );
        assert!(
            texts.iter().any(|t| t == "Select all"),
            "expected Select all inside a LazyColumn item, got {texts:?}"
        );
        assert_eq!(
            image_count(&scene),
            2,
            "a touch range selection should show two finger teardrop handles in \
             the overlay across the LazyColumn item subcomposition boundary"
        );
    }

    fn image_count(scene: &crate::renderer::RecordedRenderScene) -> usize {
        use crate::renderer::RenderOp;
        use cranpose_ui_graphics::DrawPrimitive;
        scene
            .operations()
            .iter()
            .filter(|op| {
                matches!(
                    op,
                    RenderOp::Primitive {
                        primitive: DrawPrimitive::Image { .. },
                        ..
                    }
                )
            })
            .count()
    }

    #[test]
    fn cursor_handle_shows_for_touch_only() {
        let _app_context = crate::render_state::app_context_test_scope();
        assert_eq!(
            image_count(&render_collapsed_handles(true)),
            1,
            "a touch caret should show one finger cursor handle in the overlay"
        );
        assert_eq!(
            image_count(&render_collapsed_handles(false)),
            0,
            "a mouse caret should keep a clean caret with no finger handle"
        );
    }

    #[test]
    fn basic_text_field_creates_node() {
        let _app_context = crate::render_state::app_context_test_scope();
        let mut composition = Composition::new(MemoryApplier::new());
        let state = TextFieldState::new("Test content");

        let result = composition.render(location_key(file!(), line!(), column!()), {
            let state = state.clone();
            move || {
                BasicTextField(state.clone(), Modifier::empty(), TextStyle::default());
            }
        });

        assert!(result.is_ok());
        assert!(composition.root().is_some());
    }

    #[test]
    fn basic_text_field_state_updates() {
        let _app_context = crate::render_state::app_context_test_scope();
        with_test_runtime(|| {
            let state = TextFieldState::new("Hello");
            assert_eq!(state.text(), "Hello");

            state.edit(|buffer| {
                buffer.place_cursor_at_end();
                buffer.insert("!");
            });

            assert_eq!(state.text(), "Hello!");
        });
    }

    /// Bug 2 end-to-end (headless): a `LazyColumn` provides a
    /// `BringIntoViewResponder`; its viewport rect is filled by the layout pass;
    /// asking it to reveal a caret hidden behind the keyboard scrolls the list
    /// FORWARD (revealing lower content), while asking it to reveal an
    /// already-visible caret does nothing. This pins the whole responder path:
    /// provision through the item subcomposition, the `report_window_rect`
    /// viewport sink, `scroll_delta_to_reveal`, and the `dispatch_scroll_delta`
    /// sign.
    #[test]
    fn lazy_column_responder_scrolls_a_hidden_caret_into_view() {
        use crate::bring_into_view::local_bring_into_view_responder;
        use crate::layout::LayoutEngine;
        use crate::renderer::HeadlessRenderer;
        use crate::widgets::{Box, BoxSpec, PopupHost};
        use crate::{LazyColumn, LazyColumnSpec};
        use cranpose_core::Key;
        use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
        use cranpose_ui_graphics::Size;
        use std::cell::RefCell;

        let _app_context = crate::render_state::app_context_test_scope();
        let mut composition = Composition::new(MemoryApplier::new());
        let responder_slot: Rc<RefCell<Option<crate::bring_into_view::BringIntoViewResponder>>> =
            Rc::new(RefCell::new(None));
        let state_slot: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));

        // Viewport 300x400 at window origin (0,0); 30 items of 80px each = 2400px
        // of content, so the list can scroll far forward.
        let mut content = {
            let responder_slot = Rc::clone(&responder_slot);
            let state_slot = Rc::clone(&state_slot);
            move || {
                let responder_slot = Rc::clone(&responder_slot);
                let state_slot = Rc::clone(&state_slot);
                PopupHost(move || {
                    let list_state = remember_lazy_list_state();
                    *state_slot.borrow_mut() = Some(list_state);
                    let responder_slot = Rc::clone(&responder_slot);
                    LazyColumn(
                        Modifier::empty().size(Size {
                            width: 300.0,
                            height: 400.0,
                        }),
                        list_state,
                        LazyColumnSpec::default(),
                        move |scope| {
                            let responder_slot = Rc::clone(&responder_slot);
                            scope.items(
                                30,
                                None::<fn(usize) -> u64>,
                                None::<fn(usize) -> u64>,
                                move |_index| {
                                    if responder_slot.borrow().is_none() {
                                        if let Some(r) = local_bring_into_view_responder().current()
                                        {
                                            *responder_slot.borrow_mut() = Some(r);
                                        }
                                    }
                                    Box(
                                        Modifier::empty().size(Size {
                                            width: 300.0,
                                            height: 80.0,
                                        }),
                                        BoxSpec::default(),
                                        || {},
                                    );
                                },
                            );
                        },
                    );
                });
            }
        };

        fn run_layout(
            composition: &mut Composition<MemoryApplier>,
            key: Key,
            content: &mut dyn FnMut(),
        ) {
            for _ in 0..16 {
                if !composition.should_render() {
                    break;
                }
                composition
                    .reconcile(key, &mut *content)
                    .expect("reconcile");
            }
            let root = composition.root().expect("root");
            let handle = composition.runtime_handle();
            let mut applier = composition.applier_mut();
            applier.set_runtime_handle(handle);
            let layout = applier
                .compute_layout(
                    root,
                    Size {
                        width: 400.0,
                        height: 600.0,
                    },
                )
                .expect("layout");
            applier.clear_runtime_handle();
            drop(applier);
            let _ = HeadlessRenderer::new().render(&layout);
        }

        let key = location_key(file!(), line!(), column!());
        composition.render(key, &mut content).expect("render");
        run_layout(&mut composition, key, &mut content);

        let responder = responder_slot
            .borrow()
            .clone()
            .expect("LazyColumn provides a bring-into-view responder to its items");
        let list_state = state_slot.borrow().expect("list state captured");
        let offset0 = list_state.first_visible_item_scroll_offset();
        let index0 = list_state.first_visible_item_index();

        // A caret already inside the viewport (y=100, above the fold) must not
        // scroll the list.
        responder.bring_into_view(
            Rect {
                x: 10.0,
                y: 100.0,
                width: 2.0,
                height: 20.0,
            },
            0.0,
        );
        run_layout(&mut composition, key, &mut content);
        assert_eq!(
            list_state.first_visible_item_index(),
            index0,
            "an already-visible caret must not scroll the list"
        );
        assert!(
            (list_state.first_visible_item_scroll_offset() - offset0).abs() < 0.5,
            "an already-visible caret must not scroll the list"
        );

        // A caret hidden behind a keyboard covering the bottom 250px (usable
        // region 0..150) sitting at y=360 must scroll the list forward.
        responder.bring_into_view(
            Rect {
                x: 10.0,
                y: 360.0,
                width: 2.0,
                height: 20.0,
            },
            250.0,
        );
        run_layout(&mut composition, key, &mut content);
        let scrolled_forward = list_state.first_visible_item_index() > index0
            || list_state.first_visible_item_scroll_offset() > offset0 + 0.5;
        assert!(
            scrolled_forward,
            "a caret behind the keyboard must scroll the list forward \
             (index {} -> {}, offset {:.1} -> {:.1})",
            index0,
            list_state.first_visible_item_index(),
            offset0,
            list_state.first_visible_item_scroll_offset(),
        );
    }
}