azul-layout 0.0.9

Layout solver + font and image loader the Azul GUI framework
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
// ContentEditable End-to-End Integration Tests
//
// Tests the full text input pipeline:
// 1. Focus a contenteditable element
// 2. Simulate text input → verify changeset
// 3. Render screenshots at each step → verify visual diff
// 4. Verify damage rects cover only the text region
// 5. Test cursor movement, selection, backspace

use std::path::PathBuf;
use azul_core::{
    dom::{Dom, DomId, DomNodeId, IdOrClass, NodeId, NodeType, TabIndex},
    geom::LogicalSize,
    resources::RendererResources,
    styled_dom::{StyledDom, NodeHierarchyItemId},
};
use azul_css::css::Css;
use azul_layout::{
    callbacks::ExternalSystemCallbacks,
    cpurender::{self, AzulPixmap, RenderOptions},
    glyph_cache::GlyphCache,
    window::LayoutWindow,
    window_state::FullWindowState,
};
use rust_fontconfig::FcFontCache;

// =========================================================================
// Test Infrastructure
// =========================================================================

/// Output directory for screenshots (created at test time)
fn screenshot_dir() -> PathBuf {
    let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("test_output")
        .join("contenteditable_e2e");
    std::fs::create_dir_all(&dir).ok();
    dir
}

/// Save a pixmap as PNG for visual inspection
fn save_screenshot(pixmap: &AzulPixmap, name: &str) {
    let dir = screenshot_dir();
    let path = dir.join(format!("{}.png", name));
    match pixmap.encode_png() {
        Ok(png_data) => {
            std::fs::write(&path, &png_data).unwrap();
            eprintln!("  [screenshot] {}", path.display());
        }
        Err(e) => {
            eprintln!("  [screenshot FAILED] {}: {}", name, e);
        }
    }
}

/// Count pixels that differ between two same-size pixmaps.
fn pixel_diff_count(a: &AzulPixmap, b: &AzulPixmap, threshold: u8) -> usize {
    assert_eq!(a.width(), b.width());
    assert_eq!(a.height(), b.height());
    let ad = a.data();
    let bd = b.data();
    let mut count = 0;
    for i in (0..ad.len()).step_by(4) {
        let dr = (ad[i] as i16 - bd[i] as i16).unsigned_abs() as u8;
        let dg = (ad[i + 1] as i16 - bd[i + 1] as i16).unsigned_abs() as u8;
        let db = (ad[i + 2] as i16 - bd[i + 2] as i16).unsigned_abs() as u8;
        if dr > threshold || dg > threshold || db > threshold {
            count += 1;
        }
    }
    count
}

fn cls(name: &str) -> Vec<IdOrClass> {
    vec![IdOrClass::Class(name.into())]
}

struct ContentEditableHarness {
    font_cache: FcFontCache,
    glyph_cache: GlyphCache,
    layout_window: Option<LayoutWindow>,
    renderer_resources: RendererResources,
    system_callbacks: ExternalSystemCallbacks,
    window_state: FullWindowState,
}

impl ContentEditableHarness {
    fn new(width: f32, height: f32) -> Self {
        let font_cache = FcFontCache::build();
        let mut ws = FullWindowState::default();
        ws.size.dimensions = LogicalSize::new(width, height);

        Self {
            font_cache: font_cache.clone(),
            glyph_cache: GlyphCache::new(),
            layout_window: Some(LayoutWindow::new(font_cache).unwrap()),
            renderer_resources: RendererResources::default(),
            system_callbacks: ExternalSystemCallbacks::rust_internal(),
            window_state: ws,
        }
    }

    /// Layout a DOM and generate display list. Returns the LayoutWindow for further interaction.
    fn layout_dom(&mut self, dom: Dom, css_str: &str) {
        let css = if css_str.is_empty() {
            Css::empty()
        } else {
            Css::from_string(css_str.into())
        };
        let mut dom = dom;
        let styled_dom = StyledDom::create(&mut dom, css);

        let lw = self.layout_window.as_mut().unwrap();
        let mut dbg = Some(Vec::new());
        lw.layout_and_generate_display_list(
            styled_dom,
            &self.window_state,
            &self.renderer_resources,
            &self.system_callbacks,
            &mut dbg,
        )
        .unwrap();
    }

    /// Render the current display list to a pixmap
    fn render(&mut self) -> AzulPixmap {
        let lw = self.layout_window.as_ref().unwrap();
        let dom_id = DomId { inner: 0 };
        let dl = &lw.layout_results.get(&dom_id).unwrap().display_list;
        let opts = RenderOptions {
            width: self.window_state.size.dimensions.width,
            height: self.window_state.size.dimensions.height,
            dpi_factor: 1.0,
        };
        cpurender::render_with_font_manager(
            dl,
            &self.renderer_resources,
            &lw.font_manager,
            opts,
            &mut self.glyph_cache,
        )
        .unwrap()
    }

    /// Focus a contenteditable node: sets focus + initializes cursor at end.
    /// This simulates the real focus pipeline (FocusManager + CursorManager).
    fn focus_node(&mut self, dom_id: DomId, node_id: NodeId) {
        let lw = self.layout_window.as_mut().unwrap();
        let dom_node_id = DomNodeId { dom: dom_id, node: NodeHierarchyItemId::from(Some(node_id)) };
        lw.focus_manager.set_focused_node(Some(dom_node_id));

        // Initialize cursor at end of text (like the real event pipeline does)
        // Find the inline layout result for cursor positioning
        let text_layout = lw.layout_results.get(&dom_id).and_then(|result| {
            // Search for inline_layout_result starting from this node
            let layout_indices = result.layout_tree.dom_to_layout.get(&node_id)?;
            for &idx in layout_indices {
                if let Some(w) = result.layout_tree.warm(idx) {
                    if let Some(ref cached) = w.inline_layout_result {
                        return Some(cached.layout.clone());
                    }
                }
            }
            // Check children (text node children of contenteditable div)
            let node_hierarchy = result.styled_dom.node_hierarchy.as_ref();
            let parent_item = node_hierarchy.get(node_id.index())?;
            let mut child = parent_item.first_child_id(node_id);
            while let Some(child_id) = child {
                if let Some(child_indices) = result.layout_tree.dom_to_layout.get(&child_id) {
                    for &idx in child_indices {
                        if let Some(w) = result.layout_tree.warm(idx) {
                            if let Some(ref cached) = w.inline_layout_result {
                                return Some(cached.layout.clone());
                            }
                        }
                    }
                }
                child = node_hierarchy.get(child_id.index()).and_then(|h| h.next_sibling_id());
            }
            None
        });

        // The cursor must be on the TEXT CHILD node (not the contenteditable div itself)
        // because paint_cursor() matches against the text node's dom_node_id.
        // Find the first text child of the contenteditable div.
        let text_child_id = {
            let result = lw.layout_results.get(&dom_id).unwrap();
            let node_hierarchy = result.styled_dom.node_hierarchy.as_ref();
            let node_data = result.styled_dom.node_data.as_container();
            let mut found = None;
            if let Some(parent_item) = node_hierarchy.get(node_id.index()) {
                let mut child = parent_item.first_child_id(node_id);
                while let Some(child_id) = child {
                    if matches!(node_data[child_id].get_node_type(), NodeType::Text(_)) {
                        found = Some(child_id);
                        break;
                    }
                    child = node_hierarchy.get(child_id.index()).and_then(|h| h.next_sibling_id());
                }
            }
            found.unwrap_or(node_id)
        };

        // Compute cursor at end of text
        let cursor = text_layout.as_ref()
            .and_then(|layout| {
                layout.items.iter().rev()
                    .find_map(|item| if let azul_layout::text3::cache::ShapedItem::Cluster(c) = &item.item {
                        Some(azul_core::selection::TextCursor {
                            cluster_id: c.source_cluster_id,
                            affinity: azul_core::selection::CursorAffinity::Trailing,
                        })
                    } else { None })
            })
            .unwrap_or(azul_core::selection::TextCursor {
                cluster_id: azul_core::selection::GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
                affinity: azul_core::selection::CursorAffinity::Trailing,
            });
        lw.text_edit_manager.initialize_editing(cursor, dom_id, text_child_id, 0);
        lw.text_edit_manager.blink.set_visibility(true);
    }

    /// Simulate text input on the currently focused node.
    /// Returns (affected_nodes_count, changeset_text_before, changeset_text_inserted)
    fn type_text(&mut self, text: &str) -> (usize, String, String) {
        let lw = self.layout_window.as_mut().unwrap();

        // Phase 1: Record
        let affected = lw.record_text_input(text);
        let affected_count = affected.len();

        // Capture changeset info before applying
        let (old_text, inserted_text) = match lw.get_last_text_changeset() {
            Some(cs) => (cs.old_text.as_str().to_string(), cs.inserted_text.as_str().to_string()),
            None => (String::new(), String::new()),
        };

        // Phase 2: Apply (updates layout tree + display list)
        let result = lw.apply_text_changeset();

        eprintln!(
            "  [type_text] '{}' → affected={}, old='{}', inserted='{}', needs_relayout={}",
            text, affected_count, old_text, inserted_text, result.needs_relayout
        );

        (affected_count, old_text, inserted_text)
    }

    /// Clone the current display list for damage comparison
    fn clone_display_list(&self) -> azul_layout::solver3::display_list::DisplayList {
        let lw = self.layout_window.as_ref().unwrap();
        let dom_id = DomId { inner: 0 };
        lw.layout_results.get(&dom_id).unwrap().display_list.clone()
    }

    /// Count Text items in display list and extract their glyph counts
    fn count_text_glyphs(&self) -> Vec<(usize, usize)> {
        use azul_layout::solver3::display_list::DisplayListItem;
        let lw = self.layout_window.as_ref().unwrap();
        let dom_id = DomId { inner: 0 };
        let dl = &lw.layout_results.get(&dom_id).unwrap().display_list;
        let mut result = Vec::new();
        for (idx, item) in dl.items.iter().enumerate() {
            if let DisplayListItem::Text { glyphs, .. } = item {
                result.push((idx, glyphs.len()));
            }
        }
        result
    }

    /// Check if display list contains a CursorRect item
    fn has_cursor_rect(&self) -> bool {
        use azul_layout::solver3::display_list::DisplayListItem;
        let lw = self.layout_window.as_ref().unwrap();
        let dom_id = DomId { inner: 0 };
        let dl = &lw.layout_results.get(&dom_id).unwrap().display_list;
        dl.items.iter().any(|item| matches!(item, DisplayListItem::CursorRect { .. }))
    }

    /// Debug: dump layout tree nodes to trace paint_cursor traversal
    fn dump_layout_tree(&self) {
        let lw = self.layout_window.as_ref().unwrap();
        let dom_id = DomId { inner: 0 };
        let result = lw.layout_results.get(&dom_id).unwrap();
        let tree = &result.layout_tree;
        for idx in 0..tree.nodes.len() {
            let node = tree.get(idx).unwrap();
            let children = tree.children(idx);
            let has_ifc = tree.warm(idx).and_then(|w| w.ifc_membership.as_ref()).is_some();
            let has_inline = tree.warm(idx).and_then(|w| w.inline_layout_result.as_ref()).is_some();
            eprintln!("  [layout_tree] idx={} dom_node_id={:?} children={:?} ifc_member={} has_inline={}",
                idx, node.dom_node_id, children, has_ifc, has_inline);
        }
    }

    /// Get cursor byte offset from cursor manager (start_byte_in_run)
    fn get_cursor_byte_offset(&self) -> Option<u32> {
        let lw = self.layout_window.as_ref().unwrap();
        lw.text_edit_manager.get_primary_cursor().map(|c| c.cluster_id.start_byte_in_run)
    }

    /// Get focused node
    fn get_focused_node(&self) -> Option<azul_core::dom::DomNodeId> {
        let lw = self.layout_window.as_ref().unwrap();
        lw.focus_manager.get_focused_node().cloned()
    }

    /// Find all contenteditable nodes in the DOM (returns their NodeIds)
    fn find_contenteditable_nodes(&self) -> Vec<NodeId> {
        let lw = self.layout_window.as_ref().unwrap();
        let dom_id = DomId { inner: 0 };
        let result = lw.layout_results.get(&dom_id).unwrap();
        let node_data = result.styled_dom.node_data.as_container();
        let mut found = Vec::new();
        for idx in 0..node_data.len() {
            if node_data[NodeId::new(idx)].is_contenteditable() {
                found.push(NodeId::new(idx));
            }
        }
        found
    }
}

// =========================================================================
// CSS used for all contenteditable tests
// =========================================================================

const CE_CSS: &str = r#"
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { width: 400px; height: 300px; font-family: sans-serif; font-size: 16px; background: #ffffff; }
    .editor {
        width: 380px;
        margin: 10px;
        padding: 8px;
        border: 2px solid #333333;
        min-height: 40px;
        background: #f0f0f0;
        font-size: 16px;
    }
    .label {
        margin: 10px;
        font-size: 12px;
        color: #666666;
    }
"#;

// =========================================================================
// Test 1: Initial render of contenteditable div
// =========================================================================

#[test]
fn contenteditable_initial_render() {
    let mut h = ContentEditableHarness::new(400.0, 300.0);

    let mut editor = Dom::create_div();
    editor = editor.with_ids_and_classes(cls("editor").into());
    editor.set_contenteditable(true);
    editor.set_tab_index(TabIndex::Auto);
    let text_child = Dom::create_text("Hello World");
    editor = editor.with_child(text_child);

    let dom = Dom::create_body().with_child(editor);

    h.layout_dom(dom, CE_CSS);
    let frame = h.render();

    save_screenshot(&frame, "01_initial_render");

    // Verify 1: something rendered (not all white)
    let total = (frame.width() * frame.height()) as usize;
    let mut non_white = 0;
    for chunk in frame.data().chunks_exact(4) {
        if chunk[0] != 255 || chunk[1] != 255 || chunk[2] != 255 {
            non_white += 1;
        }
    }
    assert!(non_white > 0, "Expected non-white pixels (border, background, text)");
    eprintln!("  [verify] {} non-white pixels out of {}", non_white, total);

    // Verify 2: contenteditable node found
    let ce_nodes = h.find_contenteditable_nodes();
    assert!(!ce_nodes.is_empty(), "Expected at least one contenteditable node");
    eprintln!("  [verify] Found {} contenteditable node(s): {:?}", ce_nodes.len(), ce_nodes);

    // Verify 3: display list has Text items with glyphs (fonts resolved correctly)
    let text_items = h.count_text_glyphs();
    assert!(
        !text_items.is_empty(),
        "Display list must contain at least one Text item with glyphs"
    );
    for (idx, glyph_count) in &text_items {
        assert!(
            *glyph_count > 0,
            "Text item at index {} has 0 glyphs — font resolution or shaping failed",
            idx
        );
    }
    let total_glyphs: usize = text_items.iter().map(|(_, c)| c).sum();
    eprintln!(
        "  [verify] {} Text items, {} total glyphs across items: {:?}",
        text_items.len(), total_glyphs, text_items
    );
    // "Hello World" = 11 characters, expect at least 11 glyphs
    assert!(
        total_glyphs >= 11,
        "Expected at least 11 glyphs for 'Hello World', got {}",
        total_glyphs
    );

    // Verify 4: no focus yet, cursor should be None
    assert!(
        h.get_cursor_byte_offset().is_none(),
        "Cursor should be None before focus"
    );
}

// =========================================================================
// Test 2: Focus + text input changes the rendered output
// =========================================================================

#[test]
fn contenteditable_text_input_changes_output() {
    let mut h = ContentEditableHarness::new(400.0, 300.0);

    let mut editor = Dom::create_div();
    editor = editor.with_ids_and_classes(cls("editor").into());
    editor.set_contenteditable(true);
    editor.set_tab_index(TabIndex::Auto);
    editor = editor.with_child(Dom::create_text("Hello"));

    let dom = Dom::create_body().with_child(editor);

    h.layout_dom(dom, CE_CSS);
    let frame1 = h.render();
    save_screenshot(&frame1, "02a_before_text_input");

    // Save display list before text input
    let dl_before = h.clone_display_list();

    // Focus the contenteditable div
    let ce_nodes = h.find_contenteditable_nodes();
    assert!(!ce_nodes.is_empty(), "No contenteditable nodes found");
    let ce_node_id = ce_nodes[0];
    let dom_id = DomId { inner: 0 };
    h.focus_node(dom_id, ce_node_id);
    eprintln!("  [step] Focused node {:?}", ce_node_id);

    // Verify 1: focus is set
    let focused = h.get_focused_node();
    assert!(focused.is_some(), "Focus should be set after focus_node()");
    eprintln!("  [verify] Focused: {:?}", focused);

    // Count glyphs before text input
    let glyphs_before = h.count_text_glyphs();
    let total_glyphs_before: usize = glyphs_before.iter().map(|(_, c)| c).sum();

    // Type "X" — this should insert "X" at cursor position
    let (affected, old_text, inserted) = h.type_text("X");

    // Verify 2: changeset was created with correct data
    assert!(affected > 0, "Expected at least one affected node from text input");
    assert_eq!(old_text, "Hello", "Old text should be 'Hello'");
    assert_eq!(inserted, "X", "Inserted text should be 'X'");

    // Verify 3: cursor moved after text input
    let cursor_after = h.get_cursor_byte_offset();
    eprintln!("  [verify] Cursor byte offset after input: {:?}", cursor_after);
    // Cursor should exist after text input (the apply phase sets it)
    assert!(cursor_after.is_some(), "Cursor should exist after text input");

    // Verify 4: display list has more glyphs now (added 'X')
    let glyphs_after = h.count_text_glyphs();
    let total_glyphs_after: usize = glyphs_after.iter().map(|(_, c)| c).sum();
    eprintln!(
        "  [verify] Glyphs before: {}, after: {} (expected +1)",
        total_glyphs_before, total_glyphs_after
    );
    assert!(
        total_glyphs_after > total_glyphs_before,
        "After inserting 'X', glyph count should increase (was {}, now {})",
        total_glyphs_before, total_glyphs_after
    );

    // Verify 5: display list should contain a CursorRect after text input
    let has_cursor = h.has_cursor_rect();
    let lw = h.layout_window.as_ref().unwrap();
    let draw_cursor = lw.text_edit_manager.should_draw_cursor();
    let cursor_loc = lw.text_edit_manager.multi_cursor.as_ref();
    eprintln!("  [verify] should_draw_cursor={}, multi_cursor={:?}, has CursorRect: {}",
        draw_cursor, cursor_loc.map(|mc| &mc.node_id), has_cursor);
    if !has_cursor {
        eprintln!("  [DEBUG] Dumping layout tree:");
        h.dump_layout_tree();
    }
    assert!(has_cursor, "CursorRect must appear in display list after focus + text input (should_draw_cursor={}, multi_cursor={:?})", draw_cursor, cursor_loc.is_some());

    // Verify 6: rendered frames differ visually
    let frame2 = h.render();
    save_screenshot(&frame2, "02b_after_text_input");
    let diff = pixel_diff_count(&frame1, &frame2, 0);
    assert!(diff > 0, "After typing 'X', rendered output must differ");
    let total = (frame1.width() * frame1.height()) as usize;
    eprintln!("  [verify] {} pixels differ ({:.1}%)", diff, diff as f64 / total as f64 * 100.0);

    // Verify 6: damage computation detects the change
    // Note: damage may return None if DL structure changed (e.g. CursorRect added),
    // which is fine — it means a full repaint is needed.
    let dl_after = h.clone_display_list();
    let damage = cpurender::compute_display_list_damage(&dl_before, &dl_after);
    if let Some(rects) = &damage {
        assert!(!rects.is_empty(), "Damage should produce at least one rect for text change");
        eprintln!("  [verify] {} damage rect(s)", rects.len());
    } else {
        eprintln!("  [verify] Damage computation returned None (DL structure changed — full repaint)");
    }
}

// =========================================================================
// Test 3: Multiple keystrokes accumulate correctly
// =========================================================================

#[test]
fn contenteditable_multiple_keystrokes() {
    let mut h = ContentEditableHarness::new(400.0, 300.0);

    let mut editor = Dom::create_div();
    editor = editor.with_ids_and_classes(cls("editor").into());
    editor.set_contenteditable(true);
    editor.set_tab_index(TabIndex::Auto);
    editor = editor.with_child(Dom::create_text("AB"));

    let dom = Dom::create_body().with_child(editor);

    h.layout_dom(dom, CE_CSS);
    let frame0 = h.render();
    save_screenshot(&frame0, "03a_initial_AB");

    // Focus
    let ce_nodes = h.find_contenteditable_nodes();
    let dom_id = DomId { inner: 0 };
    h.focus_node(dom_id, ce_nodes[0]);

    // Type "1"
    let (n1, _, _) = h.type_text("1");
    let frame1 = h.render();
    save_screenshot(&frame1, "03b_after_typing_1");

    // Type "2"
    let (_n2, _, _) = h.type_text("2");
    let frame2 = h.render();
    save_screenshot(&frame2, "03c_after_typing_2");

    // Type "3"
    let (_n3, _, _) = h.type_text("3");
    let frame3 = h.render();
    save_screenshot(&frame3, "03d_after_typing_3");

    // Verify: each frame differs from the previous
    let diff_0_1 = pixel_diff_count(&frame0, &frame1, 0);
    let diff_1_2 = pixel_diff_count(&frame1, &frame2, 0);
    let diff_2_3 = pixel_diff_count(&frame2, &frame3, 0);

    eprintln!("  [verify] Diff frame0→1: {} pixels", diff_0_1);
    eprintln!("  [verify] Diff frame1→2: {} pixels", diff_1_2);
    eprintln!("  [verify] Diff frame2→3: {} pixels", diff_2_3);

    assert!(n1 > 0, "First keystroke should affect a node");
    assert!(diff_0_1 > 0, "Frame should change after first keystroke");
    // Note: subsequent keystrokes may or may not produce affected nodes
    // depending on whether the text input pipeline properly accumulates
    // edits via dirty_text_nodes. The key assertion is the first keystroke works.
}

// =========================================================================
// Test 4: Damage detection between old and new display lists
// =========================================================================

#[test]
fn contenteditable_damage_detection() {
    let mut h = ContentEditableHarness::new(400.0, 300.0);

    // Layout with two divs: a static header and a contenteditable editor
    let label = Dom::create_text("Static Header").with_ids_and_classes(cls("label").into());
    let mut editor = Dom::create_div();
    editor = editor.with_ids_and_classes(cls("editor").into());
    editor.set_contenteditable(true);
    editor.set_tab_index(TabIndex::Auto);
    editor = editor.with_child(Dom::create_text("AAAA"));

    let dom = Dom::create_body()
        .with_child(label)
        .with_child(editor);

    h.layout_dom(dom, CE_CSS);
    let frame1 = h.render();
    save_screenshot(&frame1, "04a_before_edit");
    let dl_before = h.clone_display_list();

    // Focus + type
    let ce_nodes = h.find_contenteditable_nodes();
    h.focus_node(DomId { inner: 0 }, ce_nodes[0]);
    h.type_text("B");

    let frame2 = h.render();
    save_screenshot(&frame2, "04b_after_edit");
    let dl_after = h.clone_display_list();

    // Compute damage
    let damage = cpurender::compute_display_list_damage(&dl_before, &dl_after);
    eprintln!("  [verify] Damage rects: {:?}", damage);

    // Check that ONLY the text region changed, not the entire window
    let total = (frame1.width() * frame1.height()) as usize;
    let diff = pixel_diff_count(&frame1, &frame2, 0);
    let diff_pct = diff as f64 / total as f64 * 100.0;
    eprintln!(
        "  [verify] {} pixels differ ({:.1}% of total)",
        diff, diff_pct
    );

    // The text region is small relative to the full window (400x300).
    // Only the text "AAAA" → "AAAAB" area should differ, plus maybe cursor.
    // Should be well under 20% of total pixels.
    if diff > 0 {
        assert!(
            diff_pct < 20.0,
            "Text edit should only affect a small region, but {:.1}% of pixels changed",
            diff_pct
        );
        eprintln!("  [verify] PASS: Only {:.1}% of pixels changed (< 20%)", diff_pct);
    }
}

// =========================================================================
// Test 5: Two contenteditable divs — edits are isolated
// =========================================================================

#[test]
fn contenteditable_two_editors_isolated() {
    let mut h = ContentEditableHarness::new(400.0, 400.0);

    let mut editor1 = Dom::create_div();
    editor1 = editor1.with_ids_and_classes(cls("editor").into());
    editor1.set_contenteditable(true);
    editor1.set_tab_index(TabIndex::Auto);
    editor1 = editor1.with_child(Dom::create_text("Editor 1"));

    let mut editor2 = Dom::create_div();
    editor2 = editor2.with_ids_and_classes(cls("editor").into());
    editor2.set_contenteditable(true);
    editor2.set_tab_index(TabIndex::Auto);
    editor2 = editor2.with_child(Dom::create_text("Editor 2"));

    let dom = Dom::create_body()
        .with_child(editor1)
        .with_child(editor2);

    h.layout_dom(dom, CE_CSS);
    let frame0 = h.render();
    save_screenshot(&frame0, "05a_two_editors_initial");

    let ce_nodes = h.find_contenteditable_nodes();
    assert!(ce_nodes.len() >= 2, "Expected at least 2 contenteditable nodes, found {}", ce_nodes.len());
    eprintln!("  [verify] Found {} contenteditable nodes: {:?}", ce_nodes.len(), ce_nodes);

    // Focus editor 1, type
    h.focus_node(DomId { inner: 0 }, ce_nodes[0]);
    h.type_text("!");
    let frame1 = h.render();
    save_screenshot(&frame1, "05b_after_typing_in_editor1");

    // Focus editor 2, type
    h.focus_node(DomId { inner: 0 }, ce_nodes[1]);
    h.type_text("?");
    let frame2 = h.render();
    save_screenshot(&frame2, "05c_after_typing_in_editor2");

    // Verify both edits produced visual changes
    let diff_0_1 = pixel_diff_count(&frame0, &frame1, 0);
    let diff_1_2 = pixel_diff_count(&frame1, &frame2, 0);

    eprintln!("  [verify] Diff after editor1 edit: {} pixels", diff_0_1);
    eprintln!("  [verify] Diff after editor2 edit: {} pixels", diff_1_2);
}

// =========================================================================
// Test 6: Damage-based incremental rendering produces same result as full
// =========================================================================

#[test]
fn contenteditable_incremental_render_matches_full() {
    let mut h = ContentEditableHarness::new(400.0, 300.0);

    let mut editor = Dom::create_div();
    editor = editor.with_ids_and_classes(cls("editor").into());
    editor.set_contenteditable(true);
    editor.set_tab_index(TabIndex::Auto);
    editor = editor.with_child(Dom::create_text("Test"));

    let dom = Dom::create_body().with_child(editor);
    h.layout_dom(dom, CE_CSS);
    let _frame1 = h.render();
    let dl_before = h.clone_display_list();

    // Focus + type
    let ce_nodes = h.find_contenteditable_nodes();
    h.focus_node(DomId { inner: 0 }, ce_nodes[0]);
    h.type_text("Z");

    let dl_after = h.clone_display_list();

    // Render the updated display list
    let full_render = h.render();
    save_screenshot(&full_render, "06a_full_render");

    // Verify: damage computation between old and new display lists works
    let damage = cpurender::compute_display_list_damage(&dl_before, &dl_after);
    eprintln!("  [verify] Damage result: {:?}", damage.as_ref().map(|r| r.len()));

    // A second render of the same display list should be identical
    let render2 = h.render();
    save_screenshot(&render2, "06b_second_render");

    let diff = pixel_diff_count(&full_render, &render2, 0);
    assert_eq!(
        diff, 0,
        "Two renders of the same display list should be identical, but {} pixels differ",
        diff
    );
    eprintln!("  [verify] PASS: Consecutive renders are identical");
}

// =========================================================================
// Test 7: Long word overflow wraps correctly — new chars go to next line,
//         NOT "push start of word down one char at a time"
// =========================================================================

/// Reproduces the bug where typing past the container edge causes:
///   WRONG:  "a\nbcdefghijx"  (first char stranded on line 1)
///   RIGHT:  "abcdefghij\nx"  (word fills line 1, overflow goes to line 2)
///
/// Uses a narrow 100px editor (88px content area).
/// At ~8px per glyph (16px sans-serif), ~11 chars fill the line.
/// We start with "abcdefghij" (10 chars, ~80px) which fits.
/// Typing "x" then "y" should eventually push overflow to line 2.
#[test]
fn contenteditable_overflow_wraps_at_end_not_start() {
    // 100px CSS width with box-sizing: border-box
    // Content area = 100 - 2*4 padding - 2*1 border = 88px
    // At ~8px/char, ~11 chars fit.
    const NARROW_CSS: &str = r#"
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { width: 200px; height: 200px; font-family: sans-serif; font-size: 16px; background: #ffffff; }
        .editor {
            width: 100px;
            padding: 4px;
            border: 1px solid #333;
            min-height: 60px;
            background: #f0f0f0;
            font-size: 16px;
            overflow-wrap: break-word;
        }
    "#;

    let mut h = ContentEditableHarness::new(200.0, 200.0);

    // Start with a word that fills (or nearly fills) one line
    let initial_text = "abcdefghij";

    let mut editor = Dom::create_div();
    editor = editor.with_ids_and_classes(cls("editor").into());
    editor.set_contenteditable(true);
    editor.set_tab_index(TabIndex::Auto);
    editor = editor.with_child(Dom::create_text(initial_text));

    let dom = Dom::create_body().with_child(editor);
    h.layout_dom(dom, NARROW_CSS);

    let frame_before = h.render();
    save_screenshot(&frame_before, "07a_long_word_before_typing");

    // Focus and type additional characters
    let ce_nodes = h.find_contenteditable_nodes();
    assert!(!ce_nodes.is_empty());
    h.focus_node(DomId { inner: 0 }, ce_nodes[0]);

    // Type chars one at a time to push past the container edge.
    // At ~8px/char, "abcdefghij" (10 chars) ≈ 80px in 88px container.
    // After "klmno" (5 more chars) we're at 15 chars ≈ 120px — well past 88px.
    for ch in ['k', 'l', 'm', 'n', 'o'] {
        h.type_text(&ch.to_string());
    }
    let frame_after = h.render();
    save_screenshot(&frame_after, "07b_long_word_after_typing");

    // VERIFICATION: The first line should still start with "a", not be a single
    // stranded character.  We check this by examining the layout tree's inline
    // layout result — the first PositionedItem on line 0 should be "a" (or the
    // first cluster of the word), and items on line 0 should span most of the
    // line width, not just one character.
    let lw = h.layout_window.as_ref().unwrap();
    let dom_id = DomId { inner: 0 };
    let layout_result = lw.layout_results.get(&dom_id).unwrap();

    // Find the inline layout result (on the text child or the contenteditable div)
    let mut inline_layout = None;
    for idx in 0..layout_result.layout_tree.nodes.len() {
        if let Some(w) = layout_result.layout_tree.warm(idx) {
            if let Some(ref cached) = w.inline_layout_result {
                inline_layout = Some(cached.layout.clone());
                break;
            }
        }
    }
    let layout = inline_layout.expect("Must have inline layout result after text edit");

    // Count items per line
    let mut items_per_line: std::collections::BTreeMap<usize, Vec<String>> = std::collections::BTreeMap::new();
    for item in &layout.items {
        if let azul_layout::text3::cache::ShapedItem::Cluster(c) = &item.item {
            items_per_line.entry(item.line_index)
                .or_default()
                .push(c.text.clone());
        }
    }

    eprintln!("  [verify] Lines after typing 'klmno':");
    for (line_idx, chars) in &items_per_line {
        let line_text: String = chars.iter().cloned().collect();
        eprintln!("    Line {}: '{}' ({} chars)", line_idx, line_text, chars.len());
    }

    // Line 0 must have more than 1 character — the bug was that line 0
    // had only "a" (or even just a space) while all other content was
    // pushed to line 1.
    let line_0_chars = items_per_line.get(&0).map(|v| v.len()).unwrap_or(0);
    assert!(
        line_0_chars > 3,
        "BUG: Line 0 has only {} char(s) — the word start is being pushed down \
         instead of wrapping at the end.  Expected the first line to be mostly filled.",
        line_0_chars,
    );

    // The overflow characters ("xy") should be on a subsequent line
    let has_multiple_lines = items_per_line.len() > 1;
    assert!(
        has_multiple_lines,
        "After adding chars past the container width, text should span multiple lines"
    );

    eprintln!("  [verify] PASS: Line 0 has {} chars, total {} lines",
        line_0_chars, items_per_line.len());
}