slint 1.18.0

GUI toolkit to efficiently develop fluid graphical user interfaces for embedded devices and desktop applications
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
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

mod common;

use i_slint_core::input::{InternalKeyEvent, KeyEventType};
use slint::platform::software_renderer::{
    MinimalSoftwareWindow, PremultipliedRgbaColor, SoftwareRenderer, TargetPixel,
};
use std::rc::Rc;

#[allow(dead_code)]
#[derive(Clone, Copy, Default)]
struct TestPixel(bool);

impl TargetPixel for TestPixel {
    fn blend(&mut self, _color: PremultipliedRgbaColor) {
        *self = Self(true);
    }

    fn from_rgb(_red: u8, _green: u8, _blue: u8) -> Self {
        Self(true)
    }
}

const WIDTH: usize = 200;
const HEIGHT: usize = 100;

fn setup() -> Rc<MinimalSoftwareWindow> {
    common::setup(WIDTH as u32, HEIGHT as u32)
}

fn render_and_get_miss_count(renderer: &SoftwareRenderer) -> u64 {
    renderer.text_layout_cache().reset_cache_miss_count();
    let mut buf = vec![TestPixel(false); WIDTH * HEIGHT];
    renderer.render(buf.as_mut_slice(), WIDTH);
    renderer.text_layout_cache().cache_miss_count()
}

/// Renders into real pixels and counts the reddish ones. The tests below paint everything else
/// white, so a red count is "did this run of glyphs get the color it was supposed to get".
fn render_and_count_red(window: &Rc<MinimalSoftwareWindow>) -> usize {
    let mut buf = vec![slint::Rgb8Pixel::default(); WIDTH * HEIGHT];
    window.draw_if_needed(|renderer| {
        renderer.render(buf.as_mut_slice(), WIDTH);
    });
    buf.iter().filter(|p| p.r > 120 && p.g < 120 && p.b < 120).count()
}

#[test]
fn cache_hit_avoids_reshaping() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            Text {
                text: "Hello World";
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    let mut miss_count = 0u64;

    // First render: should shape at least once
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected at least one cache miss on first render");

    // Second render without changes: should hit cache
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert_eq!(miss_count, 0, "Expected zero cache misses on re-render without changes");
}

#[test]
fn text_change_invalidates_cache() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <string> label: "Hello";
            Text {
                text: label;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // First render
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });

    // Change text
    ui.set_label("Goodbye".into());

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected cache miss after text change");
}

#[test]
fn font_size_change_invalidates_cache() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <length> size: 16px;
            Text {
                text: "Hello";
                font-size: size;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // First render
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });

    // Change font-size
    ui.set_size(24.0);

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected cache miss after font-size change");
}

#[test]
fn font_weight_change_invalidates_cache() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <int> weight: 400;
            Text {
                text: "Hello";
                font-weight: weight;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // First render
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });

    // Change font-weight
    ui.set_weight(700);

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected cache miss after font-weight change");
}

#[test]
fn wrap_change_invalidates_cache() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <bool> use-no-wrap: false;
            Text {
                text: "Hello World this is a long text";
                wrap: use-no-wrap ? no-wrap : word-wrap;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // First render (word-wrap)
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });

    // Change wrap to no-wrap
    ui.set_use_no_wrap(true);

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected cache miss after wrap change");
}

#[test]
fn alignment_change_does_not_reshape() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <bool> use-center-align: false;
            Text {
                text: "Hello World";
                horizontal-alignment: use-center-align ? TextHorizontalAlignment.center : TextHorizontalAlignment.left;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // First render (left-aligned)
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });

    // Change alignment to center
    ui.set_use_center_align(true);

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert_eq!(miss_count, 0, "Alignment change should not cause reshaping");
}

#[test]
fn overflow_change_does_not_reshape() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <bool> use-elide: false;
            Text {
                text: "Hello World";
                overflow: use-elide ? TextOverflow.elide : TextOverflow.clip;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // First render (clip)
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });

    // Change overflow to elide
    ui.set_use_elide(true);

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert_eq!(miss_count, 0, "Overflow change should not cause reshaping");
}

#[test]
fn color_change_does_not_reshape() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <color> text-color: black;
            Text {
                text: "Hello World";
                color: text-color;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // First render
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });

    // Change color
    ui.set_text_color(slint::Color::from_rgb_u8(255, 0, 0));

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert_eq!(miss_count, 0, "Color change should not cause reshaping");
}

#[test]
fn scale_factor_change_invalidates_cache() {
    let window = setup();

    // Shaped glyph advances are in physical pixels, so an entry is only good for the scale factor
    // it was shaped at. The renderers drop the cache when they notice a new scale factor, but that
    // happens when rendering starts -- and the layout pass that follows the change measures first.
    slint::slint! {
        export component ScaleComponent inherits Window {
            out property <length> preferred: label.preferred-width;
            HorizontalLayout {
                alignment: start;
                label := Text {
                    text: "Hello World";
                }
            }
        }
    }

    let ui = ScaleComponent::new().unwrap();
    ui.show().unwrap();

    // Render once at 1x so that the cache holds paragraphs shaped for that scale factor.
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });
    let at_one = ui.get_preferred();
    assert!(at_one > 0.);

    // Measure at the new scale factor before rendering again, the way a real layout pass does.
    ui.window()
        .dispatch_event(slint::platform::WindowEvent::ScaleFactorChanged { scale_factor: 2.0 });
    let at_two = ui.get_preferred();

    assert!(
        (at_two - at_one).abs() <= at_one * 0.05,
        "the logical width must not follow the scale factor ({at_one} at 1x, {at_two} at 2x)"
    );
}

#[test]
fn link_color_survives_measuring() {
    let window = setup();

    // The link color is baked into the shaped glyph brushes, so measuring and drawing have to
    // shape it identically -- otherwise whichever runs first wins and the other one's color is
    // silently dropped. Layout runs first, so a mismatch shows up as unstyled link text.
    slint::slint! {
        export component TestComponent inherits Window {
            background: white;
            StyledText {
                text: @markdown("[hello](http://example.com)");
                default-color: white;
                link-color: #ff0000;
                default-font-size: 30px;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    assert!(render_and_count_red(&window) > 0, "link color was lost");
}

#[test]
fn text_input_cache_hit_avoids_reshaping() {
    let window = setup();

    slint::slint! {
        export component TestComponent inherits Window {
            in property <string> label: "Hello World";
            TextInput {
                text: label;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));

    // The first render may well find the text already shaped by the layout pass that preceded it,
    // so provoke a reshape rather than assuming one, and only then check that a redraw reuses it.
    ui.set_label("Goodbye World".into());
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected a cache miss after the text changed");

    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert_eq!(miss_count, 0, "Expected zero cache misses on re-render without changes");
}

#[test]
fn password_input_is_cached() {
    let window = setup();

    // A password field shapes a substituted text, but the substitution is the same on every path,
    // so the result is a pure function of its properties like any other text.
    slint::slint! {
        export component TestComponent inherits Window {
            in property <string> secret: "hunter2";
            TextInput {
                text: secret;
                input-type: password;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));

    ui.set_secret("correct horse".into());
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected a cache miss after the text changed");

    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert_eq!(miss_count, 0, "Expected zero cache misses on re-render without changes");
}

#[test]
fn composition_is_shaped_through_the_cache() {
    let window = setup();

    // An IME composition is part of the displayed text, so every path shapes it like any other
    // text and shares one cache entry -- drawing included, now that the composition's decoration
    // is applied by clipping at draw time instead of being baked into the glyph brushes.
    //
    // The input is sized by hand, so no layout pass measures it once the composition starts: the
    // cursor rect query is then the only thing that shapes the composed text, and the cache miss
    // it accounts for is what tells the two apart.
    slint::slint! {
        export component CursorComponent inherits Window {
            forward-focus: input;
            out property <length> cursor-x;
            out property <length> text-width: input.preferred-width;
            input := TextInput {
                x: 0; y: 0; width: 180px; height: 40px;
                text: "ab";
                wrap: no-wrap;
                cursor-position-changed(position) => { root.cursor-x = position.x; }
            }
        }
    }

    let ui = CursorComponent::new().unwrap();
    ui.show().unwrap();
    ui.window().dispatch_event(slint::platform::WindowEvent::WindowActiveChanged(true));

    // Render once so everything is shaped and settled, then start counting from zero.
    let mut buf = vec![TestPixel(false); WIDTH * HEIGHT];
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        renderer.render(buf.as_mut_slice(), WIDTH);
        renderer.text_layout_cache().reset_cache_miss_count();
    }));
    // Where the text ends without a composition, i.e. the width of "ab".
    let without_composition = ui.get_text_width();

    // Updating the composition moves the cursor, and the cursor rect is the first thing to shape
    // the composed text.
    let mut event = InternalKeyEvent::default();
    event.event_type = KeyEventType::UpdateComposition;
    event.preedit_text = "WWWWWWWWWW".into();
    // Where the IME puts the cursor: at the end of the composition.
    event.cursor_position = Some(2 + "WWWWWWWWWW".len() as i32);
    ui.window().dispatch_event(slint::platform::WindowEvent::internal(event));

    // Read the counter before rendering, so it only covers the cursor rect query above, and again
    // afterwards to see what drawing the composition adds.
    let (mut after_cursor_rect, mut after_render) = (0u64, 0u64);
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        after_cursor_rect = renderer.text_layout_cache().cache_miss_count();
        renderer.render(buf.as_mut_slice(), WIDTH);
        after_render = renderer.text_layout_cache().cache_miss_count();
    }));

    // One miss, not none: the cursor rect went through the cache rather than shaping beside it.
    assert_eq!(
        after_cursor_rect, 1,
        "the cursor rect should shape the composition through the cache"
    );
    // Drawing is served by that same entry, so it neither adds a miss nor costs the next query
    // its hit.
    assert_eq!(after_render, 1, "drawing the composition should reuse the cache entry");

    assert!(
        ui.get_cursor_x() > without_composition,
        "the cursor should sit past the composition ({} with, text ends at {without_composition})",
        ui.get_cursor_x()
    );
}

#[test]
fn text_input_selection_still_colors_text() {
    let window = setup();

    // The selection foreground is applied when drawing, by clipping the runs the selection covers,
    // so the selected text is served from the very same cache entry as the unselected text and has
    // to come out red all the same.
    slint::slint! {
        export component TestComponent inherits Window {
            background: white;
            public function select() { input.select-all(); }
            input := TextInput {
                text: "Hello";
                color: white;
                selection-background-color: white;
                selection-foreground-color: #ff0000;
                font-size: 30px;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    // Render once unselected, which populates the cache entry, and only then select.
    assert_eq!(render_and_count_red(&window), 0, "nothing should be red before selecting");

    ui.invoke_select();
    window.request_redraw();
    assert!(render_and_count_red(&window) > 0, "selection foreground color was lost");
}

#[test]
fn text_input_selection_change_does_not_reshape() {
    let window = setup();

    // Selecting is not a shaping input: the shaped glyphs are identical with and without a
    // selection, which is what keeps a drag-select from re-shaping the whole document on every
    // mouse move. Moving the cursor is the same story.
    slint::slint! {
        export component TestComponent inherits Window {
            in property <string> content: "Hello World";
            callback select(start: int, end: int);
            input := TextInput {
                text: content;
            }
            select(start, end) => {
                input.set-selection-offsets(start, end);
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    let mut miss_count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));

    // As in `text_input_cache_hit_avoids_reshaping`, the first render may find the text already
    // shaped by the layout pass before it, so provoke a miss rather than assuming one. It also
    // shows this input's draw is on the cache at all, and can therefore miss it.
    //
    // What the zero-miss assertions below catch is a selection becoming a shaping input again:
    // baking the selection colors into the glyphs makes the selection properties dependencies of
    // the entry, and each change then invalidates it. They cannot catch shaping that deliberately
    // side-steps the cache, since that records no miss to count.
    ui.set_content("Hello World!".into());
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        miss_count = render_and_get_miss_count(renderer);
    }));
    assert!(miss_count > 0, "Expected a cache miss after the text changed");

    for (start, end, what) in
        [(0, 5, "selecting"), (0, 8, "extending the selection"), (3, 8, "moving the selection")]
    {
        ui.invoke_select(start, end);
        window.request_redraw();
        assert!(window.draw_if_needed(|renderer| {
            miss_count = render_and_get_miss_count(renderer);
        }));
        assert_eq!(miss_count, 0, "{what} should not cause reshaping");
    }
}

#[test]
fn ime_composition_is_not_served_a_stale_size() {
    let window = setup();

    // Measuring and drawing share one cache entry, and the entry is invalidated by whatever the
    // path that filled it happened to read. Both must therefore shape through the same accessor:
    // if drawing filled the entry without looking at the composition, a later measurement would
    // trust that entry and size the box for the pre-composition text.
    slint::slint! {
        export component ImeComponent inherits Window {
            forward-focus: input;
            out property <length> preferred: input.preferred-width;
            HorizontalLayout {
                alignment: start;
                input := TextInput {
                    text: "ab";
                    wrap: no-wrap;
                }
            }
        }
    }

    let ui = ImeComponent::new().unwrap();
    ui.show().unwrap();
    ui.window().dispatch_event(slint::platform::WindowEvent::WindowActiveChanged(true));

    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });
    let before = ui.get_preferred();

    let mut event = InternalKeyEvent::default();
    event.event_type = KeyEventType::UpdateComposition;
    event.preedit_text = "WWWWWWWWWW".into();
    ui.window().dispatch_event(slint::platform::WindowEvent::internal(event));

    window.request_redraw();
    window.draw_if_needed(|renderer| {
        render_and_get_miss_count(renderer);
    });
    assert!(
        ui.get_preferred() > before,
        "the box should grow to fit the composition ({before} before, {} during)",
        ui.get_preferred()
    );
}

fn render_and_get_layout_miss_count(renderer: &SoftwareRenderer) -> u64 {
    renderer.text_layout_cache().reset_layout_miss_count();
    let mut buf = vec![TestPixel(false); WIDTH * HEIGHT];
    renderer.render(buf.as_mut_slice(), WIDTH);
    renderer.text_layout_cache().layout_miss_count()
}

#[test]
fn unchanged_layout_inputs_reuse_the_line_breaking() {
    let window = setup();

    // Line breaking is retained in the cache entry alongside the shaped paragraphs: a draw whose
    // breaking inputs (width, horizontal alignment, max-lines, overflow) match the retained state
    // reuses it wholesale. Anything else -- moving the item, vertical alignment -- must not break
    // lines again.
    slint::slint! {
        export component TestComponent inherits Window {
            in property <length> item-x: 0px;
            in property <length> w: 180px;
            in property <bool> align-right: false;
            in property <bool> align-bottom: false;
            in property <string> content: "Hello World this text wraps across several lines";
            TextInput {
                x: item-x; y: 0; width: w; height: 80px;
                horizontal-alignment: align-right ? TextHorizontalAlignment.right : TextHorizontalAlignment.left;
                vertical-alignment: align-bottom ? TextVerticalAlignment.bottom : TextVerticalAlignment.top;
                text: content;
                wrap: word-wrap;
            }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();

    let mut count = 0u64;
    assert!(window.draw_if_needed(|renderer| {
        count = render_and_get_layout_miss_count(renderer);
    }));

    // Moving the item redraws it at the same width: the retained breaking must be reused.
    ui.set_item_x(5.0);
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        count = render_and_get_layout_miss_count(renderer);
    }));
    assert_eq!(count, 0, "moving the item must not break its lines again");

    // The width is a breaking input.
    ui.set_w(120.0);
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        count = render_and_get_layout_miss_count(renderer);
    }));
    assert!(count > 0, "a width change must break lines again");

    // So is the horizontal alignment (parley bakes it into the line layout).
    ui.set_align_right(true);
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        count = render_and_get_layout_miss_count(renderer);
    }));
    assert!(count > 0, "an alignment change must break lines again");

    // Vertical alignment only moves the finished block; deliberately not a breaking input.
    ui.set_align_bottom(true);
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        count = render_and_get_layout_miss_count(renderer);
    }));
    assert_eq!(count, 0, "vertical alignment must not break lines again");

    // A text change reshapes, which discards the retained breaking with the entry.
    ui.set_content("Different text that also wraps across several lines".into());
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        count = render_and_get_layout_miss_count(renderer);
    }));
    assert!(count > 0, "a text change must break lines again");
}

fn reset_counts(renderer: &SoftwareRenderer) {
    renderer.text_layout_cache().reset_cache_miss_count();
    renderer.text_layout_cache().reset_layout_miss_count();
}

fn counts(renderer: &SoftwareRenderer) -> (u64, u64) {
    (
        renderer.text_layout_cache().cache_miss_count(),
        renderer.text_layout_cache().layout_miss_count(),
    )
}

#[test]
fn cursor_movement_reuses_the_cached_layout() {
    let window = setup();

    // Moving the cursor by line or by page asks the renderer two questions about the same text:
    // where the cursor is now, and which byte offset sits at the point it should move to. Neither
    // is a shaping input nor a line breaking input, so both have to come out of the entry the
    // previous draw left behind, without reshaping and without breaking lines again.
    //
    // This used to cost two or three line breaking passes per keystroke, because the entry only
    // retained the shaped glyphs. It stopped costing anything once the entry started retaining
    // the line breaking too, and this test is what keeps it that way.
    slint::slint! {
        export component TestComponent inherits Window {
            in-out property <string> content: "Hello World this text wraps across several lines and keeps going for a while";
            out property <int> offset: input.cursor-position-byte-offset;
            input := TextInput {
                x: 0; y: 0; width: 180px; height: 90px;
                // Without this, PageUp and PageDown return before they ever reach the layout.
                page-height: 40px;
                text <=> root.content;
                wrap: word-wrap;
                single-line: false;
            }
            init => { input.focus(); }
        }
    }

    let ui = TestComponent::new().unwrap();
    ui.show().unwrap();
    ui.window().dispatch_event(slint::platform::WindowEvent::WindowActiveChanged(true));

    // Draw until nothing is dirty, so the measurements below start from a fully cached state.
    let mut buf = vec![TestPixel(false); WIDTH * HEIGHT];
    while window.draw_if_needed(|renderer| {
        renderer.render(buf.as_mut_slice(), WIDTH);
    }) {}

    // As in `text_input_selection_change_does_not_reshape`, provoke a miss rather than assuming
    // one. The zeroes below only mean something if the counters can move at all, and a text
    // change is the one thing that has to move both of them.
    ui.set_content("A different string that also wraps over more than one line".into());
    let (mut shaping, mut breaking) = (0u64, 0u64);
    window.request_redraw();
    assert!(window.draw_if_needed(|renderer| {
        reset_counts(renderer);
        renderer.render(buf.as_mut_slice(), WIDTH);
        (shaping, breaking) = counts(renderer);
        // Leaves the counters at zero for the first pass through the loop below.
        reset_counts(renderer);
    }));
    assert!(
        shaping > 0 && breaking > 0,
        "a text change must reshape and break lines, or the counters aren't wired up \
         and every assertion below passes for the wrong reason"
    );

    // Home and End are left out: they only map to StartOfLine and EndOfLine off Apple platforms.
    // Both run the same two queries as the four below, differing only in the point they probe.
    for (key, what) in [
        (slint::platform::Key::DownArrow, "moving to the next line"),
        (slint::platform::Key::UpArrow, "moving to the previous line"),
        (slint::platform::Key::PageDown, "moving a page down"),
        (slint::platform::Key::PageUp, "moving a page up"),
    ] {
        let before = ui.get_offset();
        ui.window().dispatch_event(slint::platform::WindowEvent::KeyPressed { text: key.into() });

        // Read before rendering, so the draw can't add misses to what the key press cost. The
        // render and reset that follow settle the counters for the next pass.
        let (mut shaping, mut breaking) = (0u64, 0u64);
        window.request_redraw();
        assert!(window.draw_if_needed(|renderer| {
            (shaping, breaking) = counts(renderer);
            renderer.render(buf.as_mut_slice(), WIDTH);
            reset_counts(renderer);
        }));
        assert_eq!(shaping, 0, "{what} should not reshape");
        assert_eq!(breaking, 0, "{what} should not break lines again");

        // Without this the test would still pass if the key stopped moving the cursor at all,
        // since doing nothing also reshapes nothing.
        assert_ne!(before, ui.get_offset(), "{what} should have moved the cursor");
    }
}