kui-core 0.1.0-alpha.45

kui contract: flat per-frame tree, clay-style flex layout, text stack, events as data, quad display list
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
//! Selection scopes through a live `Core`: what a `selectable` container
//! makes one selection of, what a drag across it selects, what a copy
//! reads back, and how far past the viewport either reaches
//! (`docs/adr/0017-selection-as-a-scope.md`).

use kui_core::{
    Core, EditKey, InputEvent, Key, Mods, MouseButton, NodeSpec, QuadKind, Size, TextStyle, Vec2,
};

fn style() -> TextStyle {
    TextStyle::new(16.0)
}

/// A scope of three labels, one per line, in a column that selects.
fn three_labels(core: &mut Core) -> Key {
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let scope = ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
        ui.text("one", style());
        ui.text("two", style());
        ui.text("three", style());
    });
    ui.finish();
    scope
}

#[test]
fn select_all_reads_every_run_in_the_scope() {
    let mut core = Core::new();
    let scope = three_labels(&mut core);
    assert!(core.select_all_in(scope), "the scope drew three runs");
    assert_eq!(core.selection_text().as_deref(), Some("one\ntwo\nthree"));
}

#[test]
fn a_drag_selects_from_one_run_into_another() {
    let mut core = Core::new();
    let scope = three_labels(&mut core);
    // Press at the very start of the first line, drag into the third.
    assert!(core.begin_selection(scope, Vec2::new(0.0, 5.0)));
    assert_eq!(core.selection_text().as_deref(), Some(""));
    assert!(core.extend_selection(Vec2::new(1000.0, 45.0)));
    assert_eq!(core.selection_text().as_deref(), Some("one\ntwo\nthree"));
    // Dragging back to the anchor empties it again without the ends
    // swapping roles.
    assert!(core.extend_selection(Vec2::new(0.0, 5.0)));
    assert_eq!(core.selection_text().as_deref(), Some(""));
}

#[test]
fn a_selection_paints_under_the_glyphs() {
    let mut core = Core::new();
    let scope = three_labels(&mut core);
    core.select_all_in(scope);
    // Selection state alone changes nothing until a frame is built
    // against it, the way every other piece of retained state works.
    let scope2 = three_labels(&mut core);
    assert_eq!(scope, scope2);
    let quads = &core.output().0.quads;
    let tint = kui_core::select::TINT;
    let first_tint = quads.iter().position(|q| q.color == tint);
    let first_glyph = quads.iter().position(|q| q.kind != QuadKind::Solid);
    let tints = quads.iter().filter(|q| q.color == tint).count();
    assert_eq!(tints, 3, "one highlight per run");
    assert!(
        first_tint < first_glyph,
        "the highlight is pushed before the glyphs it sits under"
    );
}

#[test]
fn a_word_is_the_run_of_like_characters_around_the_point() {
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    // Where "brave" sits, measured rather than guessed: the fonts on a CI
    // container are not the fonts here, and a hard-coded x lands in a
    // different word on a different machine.
    let lead = ui.measure_text("hello ", &style(), None).width;
    let word = ui.measure_text("brave", &style(), None).width;
    let scope = ui.text_in_keyed(
        "card",
        NodeSpec::column().grow_width().selectable(),
        "hello brave world",
        style(),
    );
    ui.finish();
    assert!(
        core.select_word_at(scope, Vec2::new(lead + word / 2.0, 8.0))
            .is_some()
    );
    assert_eq!(core.selection_text().as_deref(), Some("brave"));
}

#[test]
fn a_run_scrolled_out_of_view_still_copies() {
    // Six lines of 20px in a 50px window: the last three never draw.
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let scope = ui.with_keyed(
        "scroll",
        NodeSpec::column()
            .grow_width()
            .height(50.0)
            .scroll_y()
            .selectable(),
        |ui| {
            for i in 0..6 {
                ui.text(&format!("row {i}"), style());
            }
        },
    );
    ui.finish();
    assert!(core.select_all_in(scope));
    assert_eq!(
        core.selection_text().as_deref(),
        Some("row 0\nrow 1\nrow 2\nrow 3\nrow 4\nrow 5"),
        "tier 2: a scoped run the frame culled keeps its content and its place"
    );
    // ...but nothing off-screen is under the pointer: a hit far below the
    // clip still lands in the last run that drew.
    let hit = core
        .selection_hit(scope, Vec2::new(0.0, 500.0))
        .expect("a scope with drawn runs answers");
    let ends = core
        .select_all_in(scope)
        .then(|| core.selection())
        .flatten();
    let last_drawn = ends.expect("selection").focus;
    assert_ne!(
        hit.node, last_drawn.node,
        "the last run of the scope is off-screen, so the hit is not in it"
    );
}

#[test]
fn a_long_line_joins_the_concatenation() {
    // Past LONG_LINE_BYTES and non-wrapping: shaped in chunks (C19), and
    // still one run of the scope's text (ADR 0017, open question 1).
    let long = "lorem ipsum ".repeat(500);
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let scope = ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
        ui.text("head", style());
        ui.text(
            &long,
            TextStyle {
                wrap: kui_core::spec::TextWrap::None,
                ..style()
            },
        );
    });
    ui.finish();
    assert!(core.select_all_in(scope));
    let text = core.selection_text().expect("a selection");
    assert!(text.starts_with("head\nlorem ipsum"));
    assert_eq!(text.len(), "head\n".len() + long.len());
}

#[test]
fn a_selection_is_the_windows_and_an_editor_gives_it_up() {
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let mut edit = Key::ROOT;
    let scope = ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
        ui.text("static text", style());
        edit = ui.text_edit(
            "field",
            "typed text",
            &kui_core::EditOptions {
                autofocus: true,
                ..Default::default()
            },
            NodeSpec::column().grow_width(),
        );
    });
    ui.finish();
    core.handle_input(InputEvent::Key(EditKey::SelectAll, Mods::default()));
    assert_eq!(core.copy_selection().as_deref(), Some("typed text"));
    // A selection in the scope takes the window's one selection with it.
    assert!(core.select_all_in(scope));
    assert_eq!(core.copy_selection().as_deref(), Some("static text"));
    // And the reverse (AR24): a keyboard-started editor selection takes
    // it back — the scope's is gone, not just outranked.
    core.handle_input(InputEvent::Key(EditKey::SelectAll, Mods::default()));
    assert!(
        core.selection().is_none(),
        "the scope's selection is dropped"
    );
    assert_eq!(core.copy_selection().as_deref(), Some("typed text"));
    // Shift+arrow the same; a bare arrow collapses the editor's and
    // leaves the scope's alone.
    assert!(core.select_all_in(scope));
    core.handle_input(InputEvent::Key(EditKey::Left, Mods::NONE.with_shift()));
    assert!(core.selection().is_none());
    assert!(core.select_all_in(scope));
    core.handle_input(InputEvent::Key(EditKey::Left, Mods::default()));
    assert!(
        core.selection().is_some(),
        "a caret move is not a selection"
    );
    // And a reader's `setTextSelection`.
    assert!(core.select_all_in(scope));
    let run = core.access_tree().get(edit).unwrap().runs[0].key;
    let at = |character: usize| kui_core::TextPos { run, character };
    let evs = core.handle_input(InputEvent::Access(
        kui_core::AccessRequest::new(edit, kui_core::AccessAction::SetTextSelection)
            .with_selection(at(0), at(5)),
    ));
    assert!(evs.is_empty());
    assert!(core.selection().is_none());
    assert_eq!(core.copy_selection().as_deref(), Some("typed"));
}

#[test]
fn a_press_and_drag_selects_and_a_press_outside_clears() {
    let mut core = Core::new();
    three_labels(&mut core);
    core.handle_input(InputEvent::CursorMoved(Vec2::new(0.0, 5.0)));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 1,
    });
    core.handle_input(InputEvent::CursorMoved(Vec2::new(1000.0, 45.0)));
    assert_eq!(core.selection_text().as_deref(), Some("one\ntwo\nthree"));
    core.handle_input(InputEvent::MouseUp {
        button: MouseButton::Primary,
    });
    // The pointer keeps moving after the release without extending it.
    core.handle_input(InputEvent::CursorMoved(Vec2::new(0.0, 5.0)));
    assert_eq!(core.selection_text().as_deref(), Some("one\ntwo\nthree"));
    // A press outside the scope drops it.
    three_labels(&mut core);
    core.handle_input(InputEvent::CursorMoved(Vec2::new(300.0, 250.0)));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 1,
    });
    assert_eq!(core.selection(), None);
}

#[test]
fn a_double_press_takes_the_word_and_a_triple_the_run() {
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    ui.text_in_keyed(
        "card",
        NodeSpec::column().grow_width().selectable(),
        "hello brave world",
        style(),
    );
    ui.finish();
    core.handle_input(InputEvent::CursorMoved(Vec2::new(60.0, 8.0)));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 2,
    });
    assert_eq!(core.selection_text().as_deref(), Some("brave"));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 3,
    });
    assert_eq!(core.selection_text().as_deref(), Some("hello brave world"));
}

#[test]
fn a_press_on_a_button_inside_a_scope_clicks_it() {
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
        ui.text_in_keyed(
            "go",
            NodeSpec::column()
                .grow_width()
                .height(30.0)
                .on_click(kui_core::Value::str("go")),
            "press me",
            style(),
        );
    });
    ui.finish();
    core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 10.0)));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 1,
    });
    let events = core.handle_input(InputEvent::MouseUp {
        button: MouseButton::Primary,
    });
    assert_eq!(events.len(), 1, "the click still happens: {events:?}");
    assert_eq!(
        core.selection(),
        None,
        "a control claims the press; nothing starts selecting"
    );
}

#[test]
fn a_scope_inside_a_scope_is_warned_about() {
    let mut core = Core::new();
    for _ in 0..2 {
        let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
        ui.configure_root(NodeSpec::column().fill());
        ui.with_keyed(
            "outer",
            NodeSpec::column().grow_width().selectable(),
            |ui| {
                ui.text("outside", style());
                ui.text_in_keyed(
                    "inner",
                    NodeSpec::column().grow_width().selectable(),
                    "inside",
                    style(),
                );
            },
        );
        ui.finish();
    }
    let warnings = core.take_warnings();
    assert!(
        warnings.iter().any(|w| w.code == "nested-selection-scope"),
        "got {warnings:?}"
    );
}

#[test]
fn a_copy_carries_the_formatting_the_text_declared() {
    use kui_core::Span;
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let scope = ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
        ui.rich_text(
            &[
                Span::new("plain "),
                Span::new("bold").bold(),
                Span::new(" & "),
                Span::new("green").color(kui_core::Color::rgb8(0, 0x80, 0)),
            ],
            style(),
        );
    });
    ui.finish();
    assert!(core.select_all_in(scope));
    assert_eq!(core.selection_text().as_deref(), Some("plain bold & green"));
    let html = core.selection_html().expect("formatting to carry");
    assert!(html.contains("<b>bold</b>"), "{html}");
    assert!(html.contains("color:#008000"), "{html}");
    assert!(html.contains("&amp;"), "escaped, not raw: {html}");
    assert!(
        !html.contains("plain</b>"),
        "the plain run is not swept into the bold one: {html}"
    );
}

#[test]
fn a_copy_of_a_long_line_carries_its_formatting_too() {
    // Every `break-spaces` text is a long line, shaped in chunks, and so is
    // any text past 4 KB: their spans copy as a short rich text's do
    // (backlog RG71), across a chunk's edge as well.
    use kui_core::{Color, Span, TextWrap};
    let filler = "word ".repeat(1000);
    for (lead, wrap) in [
        ("plain ", TextWrap::BreakSpaces),
        (filler.as_str(), TextWrap::Word),
        (filler.as_str(), TextWrap::BreakSpaces),
    ] {
        let mut core = Core::new();
        let mut ui = core.frame(Size::new(400.0, 4000.0), 1.0);
        ui.configure_root(NodeSpec::column().fill());
        let scope = ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
            ui.rich_text(
                &[
                    Span::new(lead),
                    Span::new("bold").bold(),
                    Span::new(" & "),
                    Span::new("green").color(Color::rgb8(0, 0x80, 0)),
                ],
                style().wrap(wrap),
            );
        });
        ui.finish();
        assert!(core.select_all_in(scope));
        let n = lead.len();
        let html = core.selection_html().expect("formatting to carry");
        assert!(html.contains("<b>bold</b>"), "{wrap:?} {n}: {html}");
        assert!(html.contains("color:#008000"), "{wrap:?} {n}: {html}");
        assert!(html.contains("&amp;"), "{wrap:?} {n}: {html}");
        assert!(!html.contains("plain</b>"), "{wrap:?} {n}: {html}");
        assert_eq!(
            html.matches("word").count(),
            lead.matches("word").count(),
            "{wrap:?} {n}"
        );
    }
}

/// A copy's bold is read against the family's own regular (the
/// regression pass over F100): a family of a Regular and a SemiBold bolds
/// at 600, and one whose only face is a Bold is regular at 700.
#[test]
fn a_copy_reads_bold_against_the_familys_regular() {
    use kui_core::Span;
    use kui_core::testing::font_face;
    let mut core = Core::new();
    let semi = core
        .add_font_data(font_face("Kui F100 Semi", 400, false, false))
        .expect("regular");
    core.add_font_data(font_face("Kui F100 Semi", 600, false, false))
        .expect("semibold");
    let heavy = core
        .add_font_data(font_face("Kui F100 Heavy", 700, false, false))
        .expect("a bold-only family");
    for (font, want_bold) in [(semi, true), (heavy, false)] {
        let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
        ui.configure_root(NodeSpec::column().fill());
        let scope = ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
            ui.rich_text(
                &[Span::new("plain "), Span::new("bold").bold()],
                style().font(font),
            );
        });
        ui.finish();
        assert!(core.select_all_in(scope));
        let html = core.selection_html().unwrap_or_default();
        assert!(!html.contains("plain</b>"), "regular read as bold: {html}");
        if want_bold {
            assert!(html.contains("<b>bold</b>"), "{html}");
        }
    }
}

#[test]
fn a_plain_selection_carries_no_theme_colour() {
    // The node's own colour is the app's theme, not the text's: a grey
    // paragraph pasted into a white document should not arrive grey.
    let mut core = Core::new();
    let scope = three_labels(&mut core);
    core.select_all_in(scope);
    let html = core.selection_html().expect("some html");
    assert!(!html.contains("color:"), "{html}");
    assert!(html.contains("one<br>two<br>three"), "{html}");
}

/// Double-click and hold, then drag: the selection moves by *words*, and
/// the word the press took stays whole when the drag turns back over it.
/// What every text UI does, and what the stock `<edit>` gets from
/// cosmic-text's `Selection::Word` for nothing.
#[test]
fn a_held_double_press_drags_by_words() {
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let at = |s: &str, ui: &mut kui_core::Ui<'_>| ui.measure_text(s, &style(), None).width;
    let x_brave = at("hello ", &mut ui) + at("brave", &mut ui) / 2.0;
    let x_world = at("hello brave ", &mut ui) + at("world", &mut ui) / 2.0;
    let x_hello = at("hell", &mut ui);
    ui.text_in_keyed(
        "card",
        NodeSpec::column().grow_width().selectable(),
        "hello brave world",
        style(),
    );
    ui.finish();

    core.handle_input(InputEvent::CursorMoved(Vec2::new(x_brave, 8.0)));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 2,
    });
    assert_eq!(core.selection_text().as_deref(), Some("brave"));
    // Held and dragged forward: whole words, not the character under the
    // pointer.
    core.handle_input(InputEvent::CursorMoved(Vec2::new(x_world, 8.0)));
    assert_eq!(core.selection_text().as_deref(), Some("brave world"));
    // Back over the start: the word it began in stays whole.
    core.handle_input(InputEvent::CursorMoved(Vec2::new(x_hello, 8.0)));
    assert_eq!(core.selection_text().as_deref(), Some("hello brave"));
    core.handle_input(InputEvent::MouseUp {
        button: MouseButton::Primary,
    });
    // The release ends the drag: moving on does not keep selecting.
    core.handle_input(InputEvent::CursorMoved(Vec2::new(x_world, 8.0)));
    assert_eq!(core.selection_text().as_deref(), Some("hello brave"));
}

/// The same gesture one click deeper: a third press held drags whole runs.
#[test]
fn a_held_triple_press_drags_by_runs() {
    let mut core = Core::new();
    let scope = three_labels(&mut core);
    let _ = scope;
    core.handle_input(InputEvent::CursorMoved(Vec2::new(4.0, 8.0)));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 3,
    });
    assert_eq!(core.selection_text().as_deref(), Some("one"));
    // Into the third label: whole runs, not a partial one.
    core.handle_input(InputEvent::CursorMoved(Vec2::new(4.0, 45.0)));
    assert_eq!(core.selection_text().as_deref(), Some("one\ntwo\nthree"));
}

/// One click still drags by characters — the gesture that was there
/// before, unchanged.
#[test]
fn a_single_press_still_drags_by_characters() {
    let mut core = Core::new();
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let lead = ui.measure_text("hello ", &style(), None).width;
    let two = ui.measure_text("hello br", &style(), None).width;
    ui.text_in_keyed(
        "card",
        NodeSpec::column().grow_width().selectable(),
        "hello brave world",
        style(),
    );
    ui.finish();
    core.handle_input(InputEvent::CursorMoved(Vec2::new(lead, 8.0)));
    core.handle_input(InputEvent::MouseDown {
        button: MouseButton::Primary,
        clicks: 1,
    });
    core.handle_input(InputEvent::CursorMoved(Vec2::new(two, 8.0)));
    assert_eq!(core.selection_text().as_deref(), Some("br"));
}

/// A keyboard user can select a label (backlog AR28): Shift with an arrow,
/// Home or End on a focused node inside a `selectable` scope moves the
/// scope's selection the way the editor's Shift-motions move its caret —
/// a character or a word at a time through the runs in order, Home and End
/// to the scope's ends — and a scope with nothing selected starts from its
/// first byte. The same reading every other selection has: `selection_text`
/// and the ends. Without Shift the arrows are what they were.
#[test]
fn shift_motions_on_a_focused_scope_select_its_text() {
    let mut core = Core::new();
    let frame = |core: &mut Core| -> Key {
        let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
        ui.configure_root(NodeSpec::column().fill());
        let scope = ui.with_keyed(
            "card",
            NodeSpec::column().grow_width().selectable().focusable(),
            |ui| {
                ui.text("one two", style());
                ui.text("three", style());
            },
        );
        ui.finish();
        scope
    };
    let scope = frame(&mut core);
    core.set_focus(Some(scope));
    frame(&mut core);
    let key = |core: &mut Core, k: EditKey, mods: Mods| core.handle_input(InputEvent::Key(k, mods));
    let shift = Mods::NONE.with_shift();
    let shift_word = Mods::NONE.with_shift().with_word();
    // Nothing selected: Shift-Right starts at the scope's first byte.
    key(&mut core, EditKey::Right, shift);
    assert_eq!(core.selection_text().as_deref(), Some("o"));
    key(&mut core, EditKey::Right, shift);
    key(&mut core, EditKey::Right, shift);
    assert_eq!(core.selection_text().as_deref(), Some("one"));
    // A word at a time: past the space to the end of the next word.
    key(&mut core, EditKey::Right, shift_word);
    assert_eq!(core.selection_text().as_deref(), Some("one two"));
    // Into the second run: the runs are one text, joined as a copy joins
    // them.
    key(&mut core, EditKey::Right, shift);
    assert_eq!(core.selection_text().as_deref(), Some("one two\nt"));
    // Back a word, then to the start: the anchor stays put, the focus
    // moves, and a focus behind the anchor is still a selection.
    key(&mut core, EditKey::Left, shift_word);
    assert_eq!(core.selection_text().as_deref(), Some("one two"));
    key(&mut core, EditKey::Home, shift);
    assert_eq!(core.selection_text().as_deref(), Some(""));
    // End selects everything from the anchor.
    key(&mut core, EditKey::End, shift);
    assert_eq!(core.selection_text().as_deref(), Some("one two\nthree"));
    assert!(
        core.focus_visible(),
        "the keyboard used the focus, so it shows"
    );
    // Without Shift an arrow is not a selection motion.
    core.clear_selection();
    key(&mut core, EditKey::Right, Mods::default());
    assert_eq!(core.selection_text(), None);
    // A control inside the scope selects the scope's text too.
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill());
    let mut button = Key::ROOT;
    ui.with_keyed("card", NodeSpec::column().grow_width().selectable(), |ui| {
        ui.text("one two", style());
        button = ui.leaf_keyed(
            "copy",
            NodeSpec::row()
                .size(20.0, 20.0)
                .on_click(kui_core::Value::str("copy")),
        );
    });
    ui.finish();
    core.set_focus(Some(button));
    key(&mut core, EditKey::End, shift);
    assert_eq!(core.selection_text().as_deref(), Some("one two"));
}