kui-core 0.1.0-alpha.44

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
//! End-to-end text editing through a live `Core`: focus, typing, motion,
//! selection, deletion, click-to-position, and event emission.

use kui_core::{Core, EditKey, EditOptions, InputEvent, Key, Mods, NodeSpec, Size, Vec2};

const SHIFT: Mods = Mods {
    shift: true,
    word: false,
    doc: false,
};
const WORD: Mods = Mods {
    shift: false,
    word: true,
    doc: false,
};

struct Rig {
    core: Core,
    key: Key,
}

impl Rig {
    fn new(initial: &str, multiline: bool) -> Rig {
        let mut core = Core::new();
        let key = frame(&mut core, initial, multiline);
        Rig { core, key }
    }

    fn frame(&mut self) {
        frame(&mut self.core, "", false); // initial/multiline only matter on create
    }

    fn text(&self) -> String {
        self.core.edit_text(self.key).unwrap()
    }

    fn type_str(&mut self, s: &str) -> usize {
        let evs = self.core.handle_input(InputEvent::Text(s.to_string()));
        evs.len()
    }

    fn press(&mut self, k: EditKey, mods: Mods) {
        self.core.handle_input(InputEvent::Key(k, mods));
    }
}

fn frame(core: &mut Core, initial: &str, multiline: bool) -> Key {
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill().pad(10.0));
    let key = ui.text_edit(
        "field",
        initial,
        &EditOptions {
            multiline,
            autofocus: true,
            ..Default::default()
        },
        NodeSpec::column().grow_width().pad(5.0),
    );
    ui.finish();
    key
}

#[test]
fn typing_inserts_at_cursor_end() {
    let mut rig = Rig::new("hello", false);
    assert!(
        rig.core.is_focused(rig.key),
        "autofocus should focus the field"
    );
    // A fresh single-line field opens with the caret after its seeded text
    // (backlog F20): no `End` press first.
    let events = rig.type_str(" world");
    assert_eq!(rig.text(), "hello world");
    assert_eq!(events, 1, "expected one changed event");
}

#[test]
fn a_seeded_document_opens_at_its_top() {
    // A multiline editor is a document: the caret opens at (0, 0), as a
    // native text view's does, so typing lands before the seed.
    let mut rig = Rig::new("second line", true);
    rig.type_str("first line\n");
    assert_eq!(rig.text(), "first line\nsecond line");
}

#[test]
fn a_returning_editor_keeps_its_draft() {
    // `initial` seeds a new editor only; the same key declared again with
    // another seed keeps what the user typed (the mind map's "abandoned
    // draft comes back" note). `set_edit_text` is what resets one.
    let mut rig = Rig::new("draft", false);
    rig.type_str("!");
    assert_eq!(rig.text(), "draft!");
    frame(&mut rig.core, "something else", false);
    assert_eq!(rig.text(), "draft!", "a redeclaration is not a reseed");
    rig.core.set_edit_text(rig.key, "reset");
    assert_eq!(rig.text(), "reset");
    rig.type_str("?");
    assert_eq!(
        rig.text(),
        "reset?",
        "set_edit_text leaves the caret at the end"
    );
}

/// A frame declaring no editor at all — the root and nothing else — for
/// the turn before a rename opens one.
fn empty_frame(core: &mut Core) {
    let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
    ui.configure_root(NodeSpec::column().fill().pad(10.0));
    ui.finish();
}

/// The key `frame` declares its editor under. Keys are paths, so this one
/// is spellable before anything declares it — which is the whole point of
/// setting an editor's text from the `update` that opens it.
fn field_key() -> Key {
    Key::ROOT.str("field")
}

#[test]
fn set_text_before_the_declare_seeds_the_editor() {
    // The `update` that opens a rename field runs a frame ahead of the
    // view that declares it, so `set_edit_text` from it names a key with
    // no editor behind it yet. The text is held for the frame that
    // declares the key and seeds it there, over `initial` (backlog F25).
    let mut core = Core::new();
    empty_frame(&mut core);
    core.set_edit_text(field_key(), "seeded");
    let key = frame(&mut core, "initial", false);
    assert_eq!(key, field_key(), "the key is spellable before the declare");
    assert_eq!(core.edit_text(key).unwrap(), "seeded");
    let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
    assert!(
        !codes.contains(&"edit-text-without-editor"),
        "a claimed seed is not a warning: {codes:?}"
    );
    // And the caret is where the call leaves it: at the end, so typing
    // extends the seeded name instead of prepending to it.
    core.handle_input(InputEvent::Text("!".into()));
    assert_eq!(core.edit_text(key).unwrap(), "seeded!");
}

#[test]
fn a_seeded_document_opens_at_its_end_not_its_top() {
    // `initial` on a multiline editor opens at the top, as a text view
    // does. A held `set_edit_text` is not `initial` — it is that call
    // arriving where it can land — so it leaves the caret where the call
    // does, at the end, document or not.
    let mut core = Core::new();
    empty_frame(&mut core);
    core.set_edit_text(field_key(), "first line");
    let key = frame(&mut core, "ignored", true);
    core.handle_input(InputEvent::Text("\nsecond line".into()));
    assert_eq!(core.edit_text(key).unwrap(), "first line\nsecond line");
}

#[test]
fn a_seed_nobody_declares_is_dropped_with_a_warning() {
    // Held for the next frame, not for ever: a key no view draws is the
    // call with its view half missing, and it says so rather than sitting
    // in the store waiting to surprise a later frame.
    let mut core = Core::new();
    core.set_edit_text(field_key(), "nowhere");
    empty_frame(&mut core);
    let warnings = core.take_warnings();
    assert_eq!(warnings.len(), 1, "{warnings:?}");
    assert_eq!(warnings[0].code, "edit-text-without-editor");
    assert_eq!(warnings[0].key, field_key());
    // Dropped, so the frame that does declare the key gets `initial`.
    let key = frame(&mut core, "initial", false);
    assert_eq!(core.edit_text(key).unwrap(), "initial");
}

#[test]
fn set_text_by_label_before_the_declare_seeds_the_editor() {
    // The key an `update` would need comes from an event the node fired,
    // and an editor being opened for the first time has fired none — so
    // the name the view itself declares is the only one the call can use
    // (backlog F32). Held for the frame that declares it, like the key.
    let mut core = Core::new();
    empty_frame(&mut core);
    core.set_edit_text_by_label("field", "seeded");
    let key = frame(&mut core, "initial", false);
    assert_eq!(core.edit_text(key).unwrap(), "seeded");
    let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
    assert!(
        !codes.contains(&"edit-text-without-editor"),
        "a claimed seed is not a warning: {codes:?}"
    );
    // The caret is where the call leaves it, as it is for a seed by key.
    core.handle_input(InputEvent::Text("!".into()));
    assert_eq!(core.edit_text(key).unwrap(), "seeded!");
}

#[test]
fn set_text_by_label_resets_a_returning_editor() {
    // The case a seed by key cannot reach. A draft is abandoned, the view
    // stops declaring the editor, and its state is retained under a key
    // that is off screen (backlog F20, F26) — which is also why `key_of`
    // will not resolve the label: no recent frame declared it. The
    // `update` that reopens the field sets the model's text by label, and
    // the editor that comes back has to show that and not the draft, so
    // the claim reaches an existing state with `set_text` where a new one
    // takes a seed.
    let mut core = Core::new();
    let key = frame(&mut core, "name", false);
    core.handle_input(InputEvent::Text(" edited".into()));
    assert_eq!(core.edit_text(key).unwrap(), "name edited");
    empty_frame(&mut core);
    assert_eq!(
        core.key_of("field"),
        None,
        "an editor closed for a frame is in no frame `key_of` reads"
    );
    core.set_edit_text_by_label("field", "name");
    let key = frame(&mut core, "name", false);
    assert_eq!(
        core.edit_text(key).unwrap(),
        "name",
        "the reopened editor shows the model's text, not the abandoned draft"
    );
    let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
    assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
}

#[test]
fn a_label_nobody_declares_is_dropped_with_a_warning() {
    // The key path's rule, in the other spelling: held for the next
    // frame, not for ever, and the line says which name went unclaimed so
    // its reader looks at the right half of the call.
    let mut core = Core::new();
    core.set_edit_text_by_label("filed", "nowhere");
    empty_frame(&mut core);
    let warnings = core.take_warnings();
    assert_eq!(warnings.len(), 1, "{warnings:?}");
    assert_eq!(warnings[0].code, "edit-text-without-editor");
    assert_eq!(warnings[0].key, Key::ROOT.str("filed"));
    assert!(
        warnings[0].message.contains("\"filed\""),
        "the label is in the message: {}",
        warnings[0].message
    );
    // Dropped, so the frame that declares the right label gets `initial`.
    let key = frame(&mut core, "initial", false);
    assert_eq!(core.edit_text(key).unwrap(), "initial");
}

/// Why the label warning is keyed `Key::ROOT.str(label)` and not one
/// constant: the dedup is once per (code, key), so a constant key would
/// report the first unclaimed name in a frame and swallow the rest — and
/// two names spelt wrong is exactly the frame where a reader needs both.
#[test]
fn two_unclaimed_labels_in_one_frame_are_two_warnings() {
    let mut core = Core::new();
    core.set_edit_text_by_label("filed", "one");
    core.set_edit_text_by_label("feild", "two");
    empty_frame(&mut core);
    let mut named: Vec<String> = core
        .take_warnings()
        .into_iter()
        .filter(|w| w.code == "edit-text-without-editor")
        .map(|w| w.message)
        .collect();
    named.sort();
    assert_eq!(named.len(), 2, "{named:?}");
    assert!(named[0].contains("\"feild\""), "{named:?}");
    assert!(named[1].contains("\"filed\""), "{named:?}");
}

#[test]
fn set_text_by_a_declared_label_lands_at_once() {
    // A label the last frame declared resolves now, so this is
    // `set_edit_text` on that key with nothing held and nothing deferred.
    let mut core = Core::new();
    let key = frame(&mut core, "name", false);
    core.set_edit_text_by_label("field", "renamed");
    assert_eq!(core.edit_text(key).unwrap(), "renamed");
    let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
    assert!(
        !codes.contains(&"edit-text-without-editor"),
        "nothing was held: {codes:?}"
    );
}

/// The call says whether the text landed or was held, in both spellings.
/// A binding redraws on the first and not the second: a redraw on a held
/// seed re-lowers the retained tree — the one that declares no editor —
/// and that frame is where the hold expires, so a `dispatch` made outside
/// the loop lost its seed to the redraw it asked for (backlog F42).
#[test]
fn set_text_says_whether_it_landed_or_was_held() {
    let mut core = Core::new();
    empty_frame(&mut core);
    assert!(
        !core.set_edit_text(field_key(), "held"),
        "a key nothing declared is held, not applied"
    );
    assert!(
        !core.set_edit_text_by_label("field", "held"),
        "a label nothing declared is held, not applied"
    );
    let key = frame(&mut core, "initial", false);
    assert!(
        core.set_edit_text(key, "landed"),
        "a declared key takes it now"
    );
    assert!(
        core.set_edit_text_by_label("field", "landed"),
        "a declared label resolves and takes it now"
    );
    assert_eq!(core.edit_text(key).unwrap(), "landed");
}

#[test]
fn backspace_and_delete() {
    let mut rig = Rig::new("abc", false);
    rig.press(EditKey::End, Mods::default());
    rig.press(EditKey::Backspace, Mods::default());
    assert_eq!(rig.text(), "ab");
    rig.press(EditKey::Home, Mods::default());
    rig.press(EditKey::Delete, Mods::default());
    assert_eq!(rig.text(), "b");
}

#[test]
fn word_motion_and_word_backspace() {
    let mut rig = Rig::new("alpha beta gamma", false);
    rig.press(EditKey::End, Mods::default());
    rig.press(EditKey::Backspace, WORD);
    assert_eq!(rig.text(), "alpha beta ");
    rig.press(EditKey::Left, WORD);
    rig.press(EditKey::Backspace, Mods::default());
    assert_eq!(rig.text(), "alphabeta ");
}

#[test]
fn shift_selection_then_type_replaces() {
    let mut rig = Rig::new("abcdef", false);
    // Select "abc" from the start, then replace it. The field opens with
    // the caret after its seed (F20), so go to the start first.
    rig.press(EditKey::Home, Mods::default());
    for _ in 0..3 {
        rig.press(EditKey::Right, SHIFT);
    }
    rig.type_str("X");
    assert_eq!(rig.text(), "Xdef");
}

#[test]
fn select_all_then_delete_clears() {
    let mut rig = Rig::new("some longer content", false);
    rig.press(EditKey::SelectAll, Mods::default());
    rig.press(EditKey::Backspace, Mods::default());
    assert_eq!(rig.text(), "");
}

#[test]
fn copy_and_cut_selection() {
    let mut rig = Rig::new("hello world", false);
    rig.press(EditKey::Home, Mods::default());
    for _ in 0..5 {
        rig.press(EditKey::Right, SHIFT);
    }
    assert_eq!(rig.core.copy_selection().as_deref(), Some("hello"));
    assert!(rig.core.take_pending_events().is_empty());
    assert_eq!(rig.core.cut_selection().as_deref(), Some("hello"));
    assert_eq!(rig.text(), " world");
    // AR15: the cut's `changed` is pending, for the driver to route the
    // way it routes a `resize` — it used to build one by hand, and only
    // for its own chord.
    let evs = rig.core.take_pending_events();
    assert_eq!(evs.len(), 1, "{evs:?}");
    assert_eq!(
        evs[0].payload.get("kind").and_then(kui_core::Value::as_str),
        Some("changed")
    );
    // Nothing selected: nothing cut, nothing posted.
    assert!(rig.core.cut_selection().is_none());
    assert!(rig.core.take_pending_events().is_empty());
}

#[test]
fn multiline_enter_splits_singleline_submits() {
    let mut multi = Rig::new("ab", true);
    multi.press(EditKey::End, Mods::default());
    multi.press(EditKey::Enter, Mods::default());
    multi.type_str("cd");
    assert_eq!(multi.text(), "ab\ncd");

    let mut single = Rig::new("ab", false);
    single.press(EditKey::End, Mods::default());
    let evs = single
        .core
        .handle_input(InputEvent::Key(EditKey::Enter, Mods::default()));
    assert_eq!(single.text(), "ab");
    assert_eq!(evs.len(), 1);
    assert_eq!(
        evs[0].payload.get("kind").and_then(kui_core::Value::as_str),
        Some("submit")
    );
}

#[test]
fn click_focuses_and_places_caret() {
    let mut rig = Rig::new("mmmm mmmm", false);
    rig.core.edit.set_focus(None);
    rig.frame(); // lay out so hit regions exist

    // Click near the left edge of the text content.
    rig.core
        .handle_input(InputEvent::CursorMoved(Vec2::new(17.0, 25.0)));
    rig.core.handle_input(InputEvent::mouse_down(1));
    rig.core.handle_input(InputEvent::mouse_up());
    assert!(rig.core.is_focused(rig.key), "click should focus");

    // Caret near the start: typing lands before most of the text.
    rig.type_str("X");
    let t = rig.text();
    assert!(
        t.starts_with('X') || t.starts_with("mX"),
        "caret should be near start, got {t}"
    );

    // Clicking outside any edit blurs.
    rig.frame();
    rig.core
        .handle_input(InputEvent::CursorMoved(Vec2::new(395.0, 295.0)));
    rig.core.handle_input(InputEvent::mouse_down(1));
    assert!(!rig.core.is_focused(rig.key), "click outside should blur");
}

#[test]
fn drag_selects_text() {
    let mut rig = Rig::new("hello world", false);
    rig.frame();
    // Press near start, drag to the right, release.
    rig.core
        .handle_input(InputEvent::CursorMoved(Vec2::new(16.0, 25.0)));
    rig.core.handle_input(InputEvent::mouse_down(1));
    rig.core
        .handle_input(InputEvent::CursorMoved(Vec2::new(120.0, 25.0)));
    rig.core.handle_input(InputEvent::mouse_up());
    let sel = rig.core.copy_selection();
    assert!(
        sel.as_deref().is_some_and(|s| s.len() > 2),
        "drag should select a few characters, got {sel:?}"
    );
}

#[test]
fn escape_blurs_and_set_text_replaces() {
    let mut rig = Rig::new("abc", false);
    rig.press(EditKey::Escape, Mods::default());
    assert!(!rig.core.is_focused(rig.key));
    rig.core.set_edit_text(rig.key, "replaced");
    assert_eq!(rig.text(), "replaced");
}

#[test]
fn selection_renders_highlight_and_caret() {
    let mut rig = Rig::new("select me", false);
    rig.press(EditKey::Home, Mods::default());
    for _ in 0..6 {
        rig.press(EditKey::Right, SHIFT);
    }
    rig.frame();
    let (dl, _) = rig.core.output();
    // Selection highlight: a solid quad wider than the 2px caret, translucent.
    let highlight = dl.quads.iter().any(|q| {
        q.kind == kui_core::QuadKind::Solid && q.rect.w > 10.0 && q.color.a < 0.9 && q.color.a > 0.1
    });
    assert!(highlight, "expected a selection highlight quad");
    // Caret: a 2px solid quad.
    let caret = dl
        .quads
        .iter()
        .any(|q| q.kind == kui_core::QuadKind::Solid && q.rect.w == 2.0);
    assert!(caret, "expected a caret quad");
}

#[test]
fn selection_highlight_stays_on_its_lines() {
    let mut rig = Rig::new("line one\nline two\nline three\nline four", true);
    // Cursor starts at buffer start; select a few chars on line two only.
    rig.press(EditKey::Down, Mods::default());
    for _ in 0..4 {
        rig.press(EditKey::Right, SHIFT);
    }
    rig.frame();
    let (dl, _) = rig.core.output();
    let ys: Vec<f32> = dl
        .quads
        .iter()
        .filter(|q| {
            q.kind == kui_core::QuadKind::Solid
                && q.rect.w > 10.0
                && q.color.a < 0.9
                && q.color.a > 0.1
        })
        .map(|q| q.rect.y)
        .collect();
    assert!(!ys.is_empty(), "expected a selection highlight");
    let (min_y, max_y) = ys
        .iter()
        .fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y)));
    // A one-line selection must highlight one line, not the rest of the doc.
    assert!(
        max_y - min_y < 1.0,
        "highlight leaked to other lines (quad tops span {min_y}..{max_y})"
    );
}