htui 0.2.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
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
//! Tests for the `<img>` layout pipeline.
//!
//! Invariants covered here:
//!   - image dimensions passed to painting are terminal-cell dimensions, not
//!     pixel dimensions.
//!   - the laid-out image width is clamped with its rect so painting cannot
//!     overflow into neighboring boxes.

use std::sync::Arc;

use crossterm::style::Color;
use htui::html::html_parse;
use htui::layout::inner_formatting_context::flow::layout_img;
use htui::layout::layout::TermMap;
use htui::layout::{
    BoxConstraint, BoxKind, Cursor, LayoutBox, LayoutContext, ReplacedContent, Viewport,
};
use htui::resources::{ResourceRegistry, with_registry};
use htui::tdom::node::{self, NodeType};
use htui::tui::graph_api::{
    ClipRect, IMAGE_PIXELS_PER_CELL_COL, IMAGE_PIXELS_PER_CELL_ROW, ImageCache, TermLayout,
    render_image_at, render_image_at_slice,
};
use image::{ImageBuffer, Rgb};

fn init() {
    let _ = env_logger::builder().is_test(true).try_init();
}

const COLS: u16 = 80;
const ROWS: u16 = 24;

fn mock_term_map() -> TermMap {
    TermMap {
        columns: COLS,
        rows: ROWS,
    }
}

fn find_img_node(arena: &node::NodeArena, root_id: node::NodeId) -> node::NodeId {
    for (id, n) in node::Node::descendants(arena, root_id) {
        if matches!(n.node_type, NodeType::Img) {
            return id;
        }
    }
    panic!("no <img> node found in parsed arena");
}

// ============================================================
// LayoutBox dimensions and image rect dimensions agree
// ============================================================

#[test]
fn layout_img_clamps_cols_to_containing_width() {
    init();
    let mut p = html_parse::Parser::new(r#"<img src="x.png" width="200" height="100">"#);
    let (root_id, arena, _) = p.parse();
    let img_id = find_img_node(&arena, root_id);

    let containing = BoxConstraint { x: 0, width: 80 };
    let mut cursor = Cursor::new();
    let lb = layout_img(
        &arena,
        img_id,
        containing,
        &mut cursor,
        LayoutContext {
            font: htui::html::css_parse::FontContext::root(),
            viewport: Viewport { cols: 80, rows: 24 },
            scroll_offsets: &htui::layout::NO_SCROLL_OFFSETS,
            scroll_x_offsets: &htui::layout::NO_SCROLL_OFFSETS,
        },
    )
    .expect("<img> should produce a LayoutBox");

    match &lb.kind {
        BoxKind::Replaced(ReplacedContent::Image { cols, rows, .. }) => {
            assert_eq!(
                *cols, lb.rect.width,
                "img cols must equal rect.width so paint does not overflow (Bug #2 fix)"
            );
            assert_eq!(*rows, lb.rect.height, "img rows must equal rect.height");
            assert!(
                lb.rect.width <= containing.width,
                "rect.width must be clamped to containing.width, got {}",
                lb.rect.width
            );
        }
        BoxKind::Normal | BoxKind::Anonymous => {
            panic!("expected BoxKind::Replaced(Image), got {:?}", lb.kind)
        }
        BoxKind::Replaced(
            ReplacedContent::Input
            | ReplacedContent::Select
            | ReplacedContent::Textarea
            | ReplacedContent::Button,
        ) => {
            panic!("expected BoxKind::Replaced(Image), got {:?}", lb.kind)
        }
    }
}

#[test]
fn layout_img_unclamped_when_img_fits() {
    init();
    let mut p = html_parse::Parser::new(r#"<img src="x.png" width="120" height="60">"#);
    let (root_id, arena, _) = p.parse();
    let img_id = find_img_node(&arena, root_id);

    // A 120px-wide image now maps to 120 cells (1 source px per column in
    // half-block mode), so an 80-column container would clamp it. Use a
    // wide-enough containing block to exercise the unclamped path.
    let containing = BoxConstraint { x: 0, width: 200 };
    let mut cursor = Cursor::new();
    let lb = layout_img(
        &arena,
        img_id,
        containing,
        &mut cursor,
        LayoutContext {
            font: htui::html::css_parse::FontContext::root(),
            viewport: Viewport { cols: 80, rows: 24 },
            scroll_offsets: &htui::layout::NO_SCROLL_OFFSETS,
            scroll_x_offsets: &htui::layout::NO_SCROLL_OFFSETS,
        },
    )
    .unwrap();

    // 120 source pixels / IMAGE_PIXELS_PER_CELL_COL (1) = 120 cells (no clamp),
    // 60 source pixels / IMAGE_PIXELS_PER_CELL_ROW (2) = 30 cells. These are
    // SOURCE-IMAGE pixels, not CSS px — see `doc/layout.md` section 10.4.
    let expected_cols = 120u32.div_ceil(IMAGE_PIXELS_PER_CELL_COL) as usize;
    let expected_rows = 60u32.div_ceil(IMAGE_PIXELS_PER_CELL_ROW) as usize;
    match &lb.kind {
        BoxKind::Replaced(ReplacedContent::Image { cols, rows, .. }) => {
            assert_eq!(*cols, expected_cols);
            assert_eq!(*rows, expected_rows);
            assert_eq!(*cols, lb.rect.width);
            assert_eq!(*rows, lb.rect.height);
        }
        _ => panic!("expected BoxKind::Replaced(Image), got {:?}", lb.kind),
    }
}

#[test]
fn image_sampling_constants_lock_in_1_by_2_ratio() {
    // Standardized source-pixel → terminal-cell sampling ratio. Each
    // terminal cell renders a 1-wide x 2-tall block of source pixels as one
    // half-block glyph (upper half = foreground color, lower half =
    // background color). Changing these values without a coordinated update
    // to `_create_img`, `layout_img`, and the doc would silently corrupt
    // image rendering.
    assert_eq!(IMAGE_PIXELS_PER_CELL_COL, 1);
    assert_eq!(IMAGE_PIXELS_PER_CELL_ROW, 2);
    assert_eq!(IMAGE_PIXELS_PER_CELL_COL * IMAGE_PIXELS_PER_CELL_ROW, 2);
}

// ============================================================
// Image raster dimensions use terminal-cell units
// ============================================================

/// Build a small 64×64 checkerboard PNG in memory and register it under a
/// unique `mem://` key, returning the key. Tests use these instead of disk
/// fixtures: the image pipeline resolves every source through the `mem://`
/// registry, so byte-fed tests exercise the exact same decode path.
fn register_test_png(reg: &ResourceRegistry, tag: &str) -> String {
    let key = format!("mem://img/{tag}_{}", std::process::id());
    let mut bytes: Vec<u8> = Vec::new();
    let img = ImageBuffer::<Rgb<u8>, _>::from_fn(64, 64, |x, y| {
        // Alternating black/white pattern — every (2×4) cell will produce
        // a non-trivial braille/quarter-block glyph.
        if (x / 4 + y / 8) % 2 == 0 {
            Rgb([255, 255, 255])
        } else {
            Rgb([0, 0, 0])
        }
    });
    img.write_to(
        &mut std::io::Cursor::new(&mut bytes),
        image::ImageFormat::Png,
    )
    .expect("encode test png");
    reg.register(&key, bytes).expect("register mem image");
    key
}

/// Build a 800×200 source PNG in memory and register it, returning the key.
/// Four times wider than tall, so a default (no attributes) `<img>` must NOT
/// lay out as a square.
fn register_wide_test_png(reg: &ResourceRegistry, tag: &str) -> String {
    let key = format!("mem://img/{tag}_wide_{}", std::process::id());
    let mut bytes: Vec<u8> = Vec::new();
    let img = ImageBuffer::<Rgb<u8>, _>::from_fn(800, 200, |_, _| Rgb([200, 100, 50]));
    img.write_to(
        &mut std::io::Cursor::new(&mut bytes),
        image::ImageFormat::Png,
    )
    .expect("encode wide test png");
    reg.register(&key, bytes).expect("register mem image");
    key
}

#[test]
fn paint_image_at_fills_requested_cell_area() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let key = register_test_png(&reg, "paint_fill");

        let term_map = mock_term_map();
        let mut tl = TermLayout::new(&term_map, 1.0);

        // Request 40 cells wide × 10 cells tall. `_create_img` multiplies the
        // vertical axis by IMAGE_PIXELS_PER_CELL_ROW (2) internally and resizes
        // exactly, so the source 64×64 PNG is squashed to 40×20 pixels
        // and rasterized into 40×10 cells.
        let cell_w: u32 = 40;
        let cell_h: u32 = 10;
        render_image_at(&mut tl, &key, (0, 0), (cell_w, cell_h))
            .expect("paint_image_at should succeed");

        // Every cell the image requested must have been written — none should
        // still hold the default space.
        let cells_per_row = term_map.columns as usize;
        for row in 0..(cell_h as usize) {
            for col in 0..(cell_w as usize) {
                let idx = row * cells_per_row + col;
                let word = &tl.layout[idx].word;
                assert_ne!(
                    word, " ",
                    "image cell at row={row} col={col} still holds default space (idx={idx})"
                );
            }
        }

        // The image uses half-block glyphs (BMP > U+007F). A
        // blank canvas would only carry ASCII spaces.
        let has_unicode = tl
            .layout
            .iter()
            .any(|b| b.word.chars().any(|c| c as u32 > 0x7F));
        assert!(
            has_unicode,
            "canvas should contain braille/quarter-block glyphs"
        );
    });
}

// ============================================================
// Cell format: half-block glyph in `word`, colors in fcolor/bcolor
// ============================================================

#[test]
fn image_cells_store_half_block_glyph_and_colors_separately() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let key = register_test_png(&reg, "cell_format");

        let term_map = mock_term_map();
        let mut tl = TermLayout::new(&term_map, 1.0);
        let cell_w: u32 = 40;
        let cell_h: u32 = 10;
        render_image_at(&mut tl, &key, (0, 0), (cell_w, cell_h))
            .expect("render_image_at should succeed");

        let cells_per_row = term_map.columns as usize;
        for row in 0..(cell_h as usize) {
            for col in 0..(cell_w as usize) {
                let cell = &tl.layout[row * cells_per_row + col];
                assert_eq!(
                    cell.word, "\u{2580}",
                    "cell word must be the upper-half-block glyph, got {:?}",
                    cell.word
                );
                assert!(
                    matches!(cell.fcolor, Some(Color::Rgb { .. })),
                    "top source-pixel color must live in fcolor, got {:?}",
                    cell.fcolor
                );
                assert!(
                    matches!(cell.bcolor, Some(Color::Rgb { .. })),
                    "bottom source-pixel color must live in bcolor, got {:?}",
                    cell.bcolor
                );
            }
        }
    });
}

// ============================================================
// ImageCache: one decode per (path, size)
// ============================================================

#[test]
fn image_cache_reuses_decoded_raster() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let key = register_test_png(&reg, "cache_reuse");
        let mut cache = ImageCache::new();

        let first = cache
            .get_or_decode(&key, (40, 10))
            .expect("first decode should succeed");
        let (cols, rows, size) = (first.columns, first.rows, first.term_size);

        // The raster is cached: unregistering the source must not break a second
        // request, which would otherwise re-resolve the bytes and fail.
        assert!(reg.unregister(&key), "unregister mem image");

        let second = cache
            .get_or_decode(&key, (40, 10))
            .expect("cached entry must serve the second request without re-reading");
        assert_eq!(cols, second.columns);
        assert_eq!(rows, second.rows);
        assert_eq!(size, second.term_size);
    });
}

#[test]
fn image_cache_keys_by_size() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let key = register_test_png(&reg, "cache_sizes");
        let mut cache = ImageCache::new();

        let (small_cols, small_rows) = {
            let small = cache
                .get_or_decode(&key, (20, 5))
                .expect("decode small size");
            (small.columns, small.rows)
        };
        let (large_cols, large_rows) = {
            let large = cache
                .get_or_decode(&key, (40, 10))
                .expect("decode large size");
            (large.columns, large.rows)
        };
        assert_eq!((small_cols, small_rows), (20, 5));
        assert_eq!((large_cols, large_rows), (40, 10));
    });
}

// ============================================================
// Intrinsic aspect ratio (default sizing without width/height attrs)
// ============================================================

#[test]
fn layout_img_default_size_preserves_intrinsic_aspect_ratio() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let key = register_wide_test_png(&reg, "aspect");

        let mut p = html_parse::Parser::new(&format!(r#"<img src="{key}">"#));
        let (root_id, arena, _) = p.parse();
        let img_id = find_img_node(&arena, root_id);

        let containing = BoxConstraint { x: 0, width: 80 };
        let mut cursor = Cursor::new();
        let lb = layout_img(
            &arena,
            img_id,
            containing,
            &mut cursor,
            LayoutContext {
                font: htui::html::css_parse::FontContext::root(),
                viewport: Viewport { cols: 80, rows: 24 },
                scroll_offsets: &htui::layout::NO_SCROLL_OFFSETS,
                scroll_x_offsets: &htui::layout::NO_SCROLL_OFFSETS,
            },
        )
        .expect("<img> should produce a LayoutBox");

        // 800×200 source capped to an 80-col container: 80 cols wide, height
        // follows the 4:1 aspect -> 80 * 200 / 800 = 20 source px = 10 rows.
        assert_eq!(lb.rect.width, 80, "default width = containing width");
        assert_eq!(
            lb.rect.height, 10,
            "default height must preserve the source aspect ratio, not be a square"
        );
    });
}

// ============================================================
// Paint clipping: crop at native scale instead of squeezing
// ============================================================

#[test]
fn render_image_at_slice_crops_to_clip_region() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        // 64×64 source: the top 16 pixel rows are black, the rest white. At full
        // scale (64×32 cells, 2 source px per cell row) the top 8 cell rows are
        // all-black half-blocks.
        let key = format!("mem://img/clip_{}", std::process::id());
        let mut bytes: Vec<u8> = Vec::new();
        let img = ImageBuffer::<Rgb<u8>, _>::from_fn(64, 64, |_, y| {
            if y < 16 {
                Rgb([0, 0, 0])
            } else {
                Rgb([255, 255, 255])
            }
        });
        img.write_to(
            &mut std::io::Cursor::new(&mut bytes),
            image::ImageFormat::Png,
        )
        .expect("encode clip png");
        reg.register(&key, bytes).expect("register clip image");

        let term_map = mock_term_map();
        // The canvas now starts at one viewport (24 rows) but this unit test
        // stamps a raw 64×32-cell grid directly through `render_image_at_slice`
        // (bypassing the paint pipeline's on-demand grow), so give it enough
        // rows to hold the full image.
        let term_map = TermMap {
            columns: term_map.columns,
            rows: 48,
        };
        let mut tl = TermLayout::new(&term_map, 1.0);
        let mut cache = ImageCache::new();
        let cells_per_row = term_map.columns as usize;

        // Decode at the FULL 64×32-cell grid but stamp only rows 0..8.
        render_image_at_slice(
            &mut cache,
            &mut tl.layout,
            term_map.columns,
            &key,
            (0, 0),
            (64, 32),
            Some(ClipRect {
                x: 0,
                y: 0,
                width: 64,
                height: 8,
            }),
        )
        .expect("render_image_at_slice should succeed");

        // Inside the clip: the top of the image at native scale — every cell is
        // a black half-block. (Squeezing the whole image into 8 rows would mix
        // in the white below the black band, so all-black rows prove the crop.)
        for row in 0..8usize {
            for col in 0..64usize {
                let cell = &tl.layout[row * cells_per_row + col];
                assert_eq!(cell.word, "\u{2580}", "clipped row must be painted");
                assert_eq!(
                    cell.fcolor,
                    Some(Color::Rgb { r: 0, g: 0, b: 0 }),
                    "clipped row must show the image's top band at native scale"
                );
            }
        }
        // Outside the clip: rows 8..32 must remain untouched blank cells.
        for row in 8..32usize {
            for col in 0..64usize {
                let cell = &tl.layout[row * cells_per_row + col];
                assert_eq!(cell.word, " ", "rows outside the clip must stay blank");
            }
        }
    });
}

// ============================================================
// SVG sources (rasterized through resvg)
// ============================================================

/// Register a small 200×100 SVG (solid orange fill) under a unique `mem://`
/// key and return the key. Byte-fed tests exercise the exact same decode path
/// as a page `<img src="mem://…svg">`.
fn register_test_svg(reg: &ResourceRegistry, tag: &str) -> String {
    let key = format!("mem://img/{tag}_svg_{}", std::process::id());
    let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
      <rect x="0" y="0" width="200" height="100" fill="#ff8800"/>
    </svg>"##;
    reg.register(&key, svg.as_bytes().to_vec())
        .expect("register mem svg");
    key
}

#[test]
fn svg_image_decodes_through_resvg() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let key = register_test_svg(&reg, "decode");

        let term_map = mock_term_map();
        let mut tl = TermLayout::new(&term_map, 1.0);
        // 200×100 source rendered into a 40×20-cell area (80×40 source pixels
        // after the half-block vertical doubling).
        render_image_at(&mut tl, &key, (0, 0), (40, 20)).expect("svg should decode and render");

        // The solid fill survives the resize: every cell carries the half-block
        // glyph with the source color in both fcolor and bcolor.
        let cells_per_row = term_map.columns as usize;
        for row in 0..20usize {
            for col in 0..40usize {
                let cell = &tl.layout[row * cells_per_row + col];
                assert_eq!(
                    cell.word, "\u{2580}",
                    "svg cell at row={row} col={col} must hold the half-block glyph"
                );
                assert_eq!(
                    cell.fcolor,
                    Some(Color::Rgb {
                        r: 0xff,
                        g: 0x88,
                        b: 0x00
                    }),
                    "svg solid fill must land in fcolor"
                );
                assert_eq!(
                    cell.bcolor,
                    Some(Color::Rgb {
                        r: 0xff,
                        g: 0x88,
                        b: 0x00
                    }),
                    "svg solid fill must land in bcolor"
                );
            }
        }
    });
}

#[test]
fn svg_intrinsic_aspect_ratio_drives_default_layout() {
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let key = register_test_svg(&reg, "aspect");

        let mut p = html_parse::Parser::new(&format!(r#"<img src="{key}">"#));
        let (root_id, arena, _) = p.parse();
        let img_id = find_img_node(&arena, root_id);

        let containing = BoxConstraint { x: 0, width: 80 };
        let mut cursor = Cursor::new();
        let lb = layout_img(
            &arena,
            img_id,
            containing,
            &mut cursor,
            LayoutContext {
                font: htui::html::css_parse::FontContext::root(),
                viewport: Viewport { cols: 80, rows: 24 },
                scroll_offsets: &htui::layout::NO_SCROLL_OFFSETS,
                scroll_x_offsets: &htui::layout::NO_SCROLL_OFFSETS,
            },
        )
        .expect("<img> should produce a LayoutBox");

        // 200×100 SVG capped to an 80-col container: 80 cols wide, height follows
        // the 2:1 aspect -> 80 * 100 / 200 = 40 source px = 20 cells.
        assert_eq!(lb.rect.width, 80, "default width = containing width");
        assert_eq!(
            lb.rect.height, 20,
            "default height must follow the SVG intrinsic aspect ratio"
        );
    });
}

// ============================================================
// <base href> — relative `src` resolution
// ============================================================

#[test]
fn layout_img_resolves_src_against_document_base() {
    // A relative `<img src>` must resolve against `<base href>` at
    // generation time, so the geometry/paint phases only ever see the
    // absolute `mem://` key.
    init();
    let reg = Arc::new(ResourceRegistry::new());
    with_registry(Arc::clone(&reg), || {
        let pid = std::process::id();
        let key = format!("mem://dir/pic_{pid}.png");
        reg.register(&key, vec![0u8; 16])
            .expect("register mem image");

        let html = format!(
            "<base href=\"mem://dir/\"><img src=\"pic_{pid}.png\" width=\"20\" height=\"10\">"
        );
        let (root_id, arena, _) = html_parse::Parser::new(&html).parse();
        let term_map = mock_term_map();
        let mut tl = TermLayout::new(&term_map, 1.0);
        let boxes = tl.draw(&arena, root_id, &term_map);

        // The img box sits under the implicit <html>/<body> wrappers, so search
        // the whole box tree.
        let img_box = find_img_box(&boxes).expect("img must produce a replaced layout box");
        match &img_box.kind {
            BoxKind::Replaced(ReplacedContent::Image { path, .. }) => {
                assert_eq!(
                    path.as_str(),
                    key.as_str(),
                    "relative img src must resolve against <base href>"
                );
            }
            _ => unreachable!(),
        }
    });
}

fn find_img_box(boxes: &[LayoutBox]) -> Option<&LayoutBox> {
    boxes.iter().find_map(|b| match &b.kind {
        BoxKind::Replaced(ReplacedContent::Image { .. }) => Some(b),
        _ => find_img_box(&b.children),
    })
}

// ============================================================
// Placement: margins active, left-aligned by default
// ============================================================

#[test]
fn layout_img_respects_margin_and_left_aligns() {
    init();
    // An `<img>` is inline-level: left-aligned in its flow with active CSS
    // margins, matching browser inline replaced-element placement.
    let mut p = html_parse::Parser::new(
        r#"<img src="x.png" width="80" height="24" style="margin-left: 16px">"#,
    );
    let (root_id, mut arena, _) = p.parse();
    // Inline styles land in `css_attr` only via the cascade sweep — run it
    // with an empty rule set so the `style=` attribute applies.
    let mut css_parser = htui::html::css_parse::Parser::new("");
    htui::html::css_parse::css_into_node(&mut arena, root_id, &css_parser.parse());
    let img_id = find_img_node(&arena, root_id);

    let containing = BoxConstraint { x: 0, width: 80 };
    let mut cursor = Cursor::new();
    let lb = layout_img(
        &arena,
        img_id,
        containing,
        &mut cursor,
        LayoutContext {
            font: htui::html::css_parse::FontContext::root(),
            viewport: Viewport { cols: 80, rows: 24 },
            scroll_offsets: &htui::layout::NO_SCROLL_OFFSETS,
            scroll_x_offsets: &htui::layout::NO_SCROLL_OFFSETS,
        },
    )
    .expect("<img> should produce a LayoutBox");

    assert_eq!(
        lb.rect.x, 2,
        "margin-left: 16px resolves to 2 cells (8px per cell); the image must \
         shift right instead of centring"
    );
    assert_eq!(
        lb.rect.width, 80,
        "the HTML width attribute is interpreted as a cell count (80), \
         unchanged by the margin"
    );
}