use crossterm::style::Color;
use htui::html::html_parse::Parser;
use htui::html::{css_parse, html_parse};
use htui::layout::layout::TermMap;
use htui::tdom::node::{NodeArena, NodeId};
use htui::tui::graph_api::{FontStyle, TermLayout};
fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
fn setup(html: &str, css: &str) -> (NodeId, NodeArena) {
let mut html_parser = html_parse::Parser::new(html);
let (root_id, mut arena, embedded) = html_parser.parse();
let combined = if css.is_empty() {
embedded
} else if embedded.is_empty() {
css.to_string()
} else {
format!("{embedded}\n{css}")
};
if !combined.trim().is_empty() {
let mut css_parser = css_parse::Parser::new(&combined);
let rules = css_parser.parse();
css_parse::css_into_node(&mut arena, root_id, &rules);
}
(root_id, arena)
}
const COLS: u16 = 80;
const ROWS: u16 = 24;
fn mock_term_map() -> TermMap {
TermMap {
columns: COLS,
rows: ROWS,
}
}
#[test]
fn test_layout_p() {
let mut p = Parser::new("<p>Hello</p><p>World</p>");
let (root, arena, _) = p.parse();
let term_map = TermMap {
columns: 80,
rows: 24,
};
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root, &term_map);
let canvas: String = tl.layout.iter().map(|b| b.word.as_str()).collect();
assert!(canvas.contains("Hello"));
assert!(canvas.contains("World"));
}
#[test]
fn test_layout_p_text_align_center() {
init();
let html = "<p>Centered</p>";
let css = "p { text-align: center; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word != " ")
.expect("should find text");
let row = text_idx / cols;
let rstart = row * cols;
let leading_spaces = tl.layout[rstart..rstart + cols]
.iter()
.take_while(|b| b.word == " ")
.count();
let text_str_width = "Centered".len();
let expected_padding = (cols - text_str_width) / 2;
assert_eq!(
leading_spaces, expected_padding,
"<p> text should be centered within row"
);
}
#[test]
fn test_layout_p_text_align_right() {
init();
let html = "<p>Right</p>";
let css = "p { text-align: right; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word != " ")
.expect("should find text");
let row = text_idx / cols;
let rstart = row * cols;
let leading_spaces = tl.layout[rstart..rstart + cols]
.iter()
.take_while(|b| b.word == " ")
.count();
let text_str_width = "Right".len();
let expected_padding = cols - text_str_width;
assert_eq!(
leading_spaces, expected_padding,
"<p> text should be right-aligned within row"
);
}
#[test]
fn test_layout_p_text_align_inherited_from_div() {
init();
let html = "<div><p>centered</p></div>";
let css = "div { text-align: center; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word == "c")
.expect("should find 'centered'");
let row = text_idx / cols;
let col_in_row = text_idx % cols;
let text_str_width = "centered".len();
let expected_padding = (cols - text_str_width) / 2;
assert_eq!(
col_in_row, expected_padding,
"<p> should inherit text-align from <div>, row={row}"
);
}
#[test]
fn test_layout_h1_text_align_center() {
init();
let html = "<h1>Title</h1>";
let css = "h1 { text-align: center; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word == "#")
.expect("should find heading '#' prefix");
let col_in_row = text_idx % cols;
let rendered_width = "# Title".chars().count() * 2;
let expected_padding = (cols - rendered_width) / 2;
assert_eq!(
col_in_row, expected_padding,
"<h1> should be centered including '# ' prefix"
);
}
#[test]
fn test_layout_h2_text_align_right() {
init();
let html = "<h2>Sub</h2>";
let css = "h2 { text-align: right; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word == "#")
.expect("should find heading '#' prefix");
let col_in_row = text_idx % cols;
let rendered_width = "## Sub".chars().count() * 2;
let expected_padding = cols - rendered_width;
assert_eq!(
col_in_row, expected_padding,
"<h2> should be right-aligned including '## ' prefix"
);
}
#[test]
fn test_layout_heading_text_align_inherited_from_div() {
init();
let html = "<div><h1>Heading</h1></div>";
let css = "div { text-align: center; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word == "#")
.expect("should find heading prefix");
let col_in_row = text_idx % cols;
let rendered_width = "# Heading".chars().count() * 2;
let expected_padding = (cols - rendered_width) / 2;
assert_eq!(
col_in_row, expected_padding,
"<h1> should inherit text-align from <div>"
);
}
#[test]
fn test_layout_p_text_align_default_left() {
init();
let html = "<p>Left</p>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let text_idx = tl
.layout
.iter()
.position(|b| b.word != " ")
.expect("should find text");
let row = text_idx / cols;
let col_in_row = text_idx % cols;
assert_eq!(
col_in_row, 0,
"default text-align should leave text at column 0, row={row}"
);
}
fn canvas_rows(tl: &TermLayout, cols: usize) -> Vec<String> {
tl.layout
.chunks(cols)
.map(|row| {
let s: String = row.iter().map(|b| b.word.as_str()).collect();
s.trim_end().to_string()
})
.collect()
}
#[test]
fn visibility_hidden_keeps_layout_space_but_paints_nothing() {
init();
let html = "<div><p>a</p><p class=\"hid\">b</p><p>c</p></div>";
let css = ".hid { visibility: hidden; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let rows = canvas_rows(&tl, term_map.columns as usize);
assert!(
!rows.iter().any(|r| r.contains('b')),
"hidden content must not paint"
);
let row_a = rows.iter().position(|r| r.contains('a')).expect("a row");
let row_c = rows.iter().position(|r| r.contains('c')).expect("c row");
assert_eq!(
row_c,
row_a + 4,
"the hidden <p> keeps its layout space; a and c are 4 rows apart (text + margin + hidden text + margin)"
);
}
#[test]
fn visibility_hidden_child_block_with_visible_restores() {
init();
let html = "<div class=\"hid\">x<div class=\"vis\">y</div></div>";
let css = ".hid { visibility: hidden; } .vis { visibility: visible; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let canvas: String = tl.layout.iter().map(|b| b.word.as_str()).collect();
assert!(
!canvas.contains('x'),
"the hidden parent's own content must not paint"
);
assert!(
canvas.contains('y'),
"a block child overriding visibility: visible must paint"
);
}
#[test]
fn term_layout_caps_a_pathological_viewport_size() {
let term_map = TermMap {
columns: u16::MAX,
rows: u16::MAX,
};
let tl = TermLayout::new(&term_map, 1.0);
let max_cells =
htui::layout::layout::MAX_TERM_COLS as usize * htui::layout::layout::MAX_TERM_ROWS as usize;
assert!(
tl.layout.len() <= max_cells,
"canvas must be bounded by the size clamps, got {}",
tl.layout.len()
);
}
#[test]
fn term_layout_caps_an_extreme_but_plausible_viewport() {
let term_map = TermMap {
columns: 3000,
rows: 1000,
};
let tl = TermLayout::new(&term_map, 1.0);
assert!(tl.layout.len() <= 1000 * 500);
}
#[test]
fn test_layout_h6_prefix_renders() {
init();
let html = "<h6>Small</h6>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let hash_idx = tl
.layout
.iter()
.position(|b| b.word == "#")
.expect("h6 '######' prefix should render");
let row = hash_idx / cols;
let rstart = row * cols;
let row_cells = &tl.layout[rstart..rstart + cols];
let prefix: String = row_cells[0..7].iter().map(|b| b.word.as_str()).collect();
assert_eq!(
prefix, "###### ",
"h6 must render the 6-level markdown prefix"
);
let text: String = row_cells[7..12].iter().map(|b| b.word.as_str()).collect();
assert_eq!(
text, "Small",
"heading text must follow the prefix on the same row"
);
}
#[test]
fn test_layout_hgroup_renders_heading_children() {
init();
let html = "<hgroup><h4>Grouped</h4><p>tagline</p></hgroup>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let canvas: String = tl.layout.iter().map(|b| b.word.as_str()).collect();
assert!(
canvas.contains("#### Grouped"),
"hgroup member heading must render with its own prefix"
);
assert!(
canvas.contains("tagline"),
"hgroup's trailing <p> child must render"
);
}
#[test]
fn test_layout_address_italic_default() {
init();
let html = "<address>Contact</address>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx = tl
.layout
.iter()
.position(|b| b.word == "C")
.expect("address text must render");
let cell = &tl.layout[idx];
assert!(
cell.style.contains(FontStyle::ITALIC),
"default <address> rendering must be italic (got style {:?})",
cell.style
);
}
#[test]
fn test_layout_address_font_style_normal_disables_italic() {
init();
let html = "<address>Contact</address>";
let css = "address { font-style: normal; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx = tl
.layout
.iter()
.position(|b| b.word == "C")
.expect("address text must render");
let cell = &tl.layout[idx];
assert!(
!cell.style.contains(FontStyle::ITALIC),
"`address {{ font-style: normal }}` must suppress the UA italic default (got {:?})",
cell.style
);
}
#[test]
fn test_layout_address_inherits_italic_from_ancestor() {
init();
let html = "<div><address>Contact</address></div>";
let css = "div { font-style: italic; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx = tl
.layout
.iter()
.position(|b| b.word == "C")
.expect("address text must render");
let cell = &tl.layout[idx];
assert!(
cell.style.contains(FontStyle::ITALIC),
"<address> must inherit font-style from its ancestor (got {:?})",
cell.style
);
}
#[test]
fn inline_block_boxes_flow_side_by_side() {
init();
let html = r#"
<div class="card">AA</div>
<div class="card">BB</div>
<div class="card">CC</div>
"#;
let css = ".card { display: inline-block; width: 20ch; margin-right: 2ch; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx_a = tl.layout.iter().position(|b| b.word == "A").unwrap();
let idx_b = tl.layout.iter().position(|b| b.word == "B").unwrap();
let idx_c = tl.layout.iter().position(|b| b.word == "C").unwrap();
assert_eq!(idx_a / 80, 0, "all cards share the first row");
assert_eq!(idx_b / 80, 0);
assert_eq!(idx_c / 80, 0);
assert_eq!(
idx_b,
idx_a + 23,
"second card starts after 20 + 2 margin + 1 collapsed space"
);
assert_eq!(idx_c, idx_b + 23, "third card follows the same spacing");
}
#[test]
fn inline_row_skips_unlayouttable_item_without_shifting_siblings() {
init();
let html = r#"<div><span class="a">AA</span><span class="g" style="display: inline-grid">G</span><span class="b">BBB</span></div>"#;
let css = ".a, .b { display: inline-block; } .b { background-color: navy; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let row0: String = tl.layout[..80].iter().map(|b| b.word.as_str()).collect();
assert_eq!(
row0.find("AA"),
Some(0),
"the first inline item keeps its own measurements: {row0:?}"
);
let navy = Color::Rgb {
r: 0,
g: 0,
b: 0x80,
};
let painted = tl.layout[..80]
.iter()
.any(|b| b.word == "B" && b.bcolor == Some(navy));
assert!(
painted,
"the sibling after the unlayouttable item must keep its own measurements \
and place its box (background fill) on the row: {row0:?}"
);
}
#[test]
fn inline_block_whitespace_separates_and_collapses() {
init();
let html = "<div class=\"a\">A</div> <div class=\"b\">B</div><div class=\"c\">C</div>";
let css = ".a, .b, .c { display: inline-block; width: 10ch; margin-right: 2ch; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx_a = tl.layout.iter().position(|b| b.word == "A").unwrap();
let idx_b = tl.layout.iter().position(|b| b.word == "B").unwrap();
let idx_c = tl.layout.iter().position(|b| b.word == "C").unwrap();
assert_eq!(
idx_b,
idx_a + 13,
"whitespace run collapses to one cell: 10 + 2 + 1"
);
assert_eq!(idx_c, idx_b + 12, "no whitespace: margin only (10 + 2)");
}
#[test]
fn inline_block_line_start_space_is_removed() {
init();
let html = "<div class=\"a\">A</div> <div class=\"b\">B</div>";
let css = ".a, .b { display: inline-block; width: 10ch; margin-right: 2ch; }";
let (root_id, arena) = setup(html, css);
let term_map = TermMap {
columns: 11,
rows: 8,
};
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx_a = tl.layout.iter().position(|b| b.word == "A").unwrap();
let idx_b = tl.layout.iter().position(|b| b.word == "B").unwrap();
assert_eq!(idx_a % 11, 0, "first card at column 0");
assert_eq!(
idx_b / 11,
idx_a / 11 + 1,
"second card wrapped to the next row"
);
assert_eq!(
idx_b % 11,
0,
"wrapped card starts at column 0 (leading space removed)"
);
}
#[test]
fn inline_block_keeps_its_own_box_and_width() {
init();
let html = r#"<div class="card">hi</div>"#;
let css = ".card { display: inline-block; width: 10ch; border: 1ch solid red; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
assert_eq!(tl.layout[0].word, "┌");
assert_eq!(tl.layout[9].word, "┐");
assert_eq!(tl.layout[80 + 1].word, "h");
assert_eq!(tl.layout[80 + 2].word, "i");
}
#[test]
fn inline_block_wraps_at_line_width() {
init();
let html = r#"
<div class="card">AA</div>
<div class="card">BB</div>
<div class="card">CC</div>
"#;
let css = ".card { display: inline-block; width: 39ch; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx_a = tl.layout.iter().position(|b| b.word == "A").unwrap();
let idx_b = tl.layout.iter().position(|b| b.word == "B").unwrap();
let idx_c = tl.layout.iter().position(|b| b.word == "C").unwrap();
assert_eq!(idx_a / 80, 0);
assert_eq!(idx_b / 80, 0, "second card stays on row 1");
assert_eq!(idx_c / 80, 1, "third card wraps to row 2");
assert_eq!(
idx_b,
idx_a + 40,
"second card follows by 39 + 1 collapsed space"
);
}
#[test]
fn test_layout_hr_renders_rule_across_width() {
init();
let html = "<hr>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx = tl
.layout
.iter()
.position(|b| b.word == "─")
.expect("hr rule must render");
let row = idx / cols;
let rstart = row * cols;
let rule_len = tl.layout[rstart..rstart + cols]
.iter()
.take_while(|b| b.word == "─")
.count();
assert_eq!(
rule_len, cols,
"hr must span the full content width (got {rule_len} cells)"
);
}
#[test]
fn test_layout_strong_b_bold() {
init();
let html = "<p>alpha <strong>sbold</strong> <b>bravo</b></p>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let strong_idx = tl
.layout
.iter()
.position(|b| b.word == "s")
.expect("strong text must render");
let b_idx = tl
.layout
.iter()
.position(|b| b.word == "b")
.expect("b text must render");
let plain_idx = tl
.layout
.iter()
.position(|b| b.word == "a")
.expect("plain text must render");
assert!(
tl.layout[strong_idx].style.contains(FontStyle::BOLD),
"<strong> must be bold (got {:?})",
tl.layout[strong_idx].style
);
assert!(
tl.layout[b_idx].style.contains(FontStyle::BOLD),
"<b> must be bold (got {:?})",
tl.layout[b_idx].style
);
assert!(
!tl.layout[plain_idx].style.contains(FontStyle::BOLD),
"plain text must not be bold"
);
}
#[test]
fn test_layout_text_semantics_styles() {
init();
let html = "<p><i>italic</i> <u>under</u> <s>strike</s> <mark>mark</mark></p>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let find = |word: &str| {
tl.layout
.iter()
.position(|b| b.word == word)
.unwrap_or_else(|| panic!("cell {word:?} must render"))
};
assert!(
tl.layout[find("i")].style.contains(FontStyle::ITALIC),
"<i> must be italic"
);
assert!(
tl.layout[find("u")].style.contains(FontStyle::UNDERLINED),
"<u> must be underlined"
);
assert!(
tl.layout[find("s")].style.contains(FontStyle::CROSSED_OUT),
"<s> must be line-through"
);
let mark_idx = find("m");
assert_eq!(
tl.layout[mark_idx].bcolor,
Some(Color::Rgb {
r: 255,
g: 255,
b: 0
}),
"<mark> must paint the UA yellow background"
);
assert_eq!(
tl.layout[mark_idx].fcolor,
Some(Color::Rgb { r: 0, g: 0, b: 0 }),
"<mark> must paint black foreground"
);
}
#[test]
fn test_layout_edits_and_citation_styles() {
init();
let html = "<p><ins>1</ins><del>2</del><cite>3</cite><dfn>4</dfn><var>5</var></p>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let find = |word: &str| {
tl.layout
.iter()
.position(|b| b.word == word)
.unwrap_or_else(|| panic!("cell {word:?} must render"))
};
assert!(
tl.layout[find("1")].style.contains(FontStyle::UNDERLINED),
"<ins> must be underlined"
);
assert!(
tl.layout[find("2")].style.contains(FontStyle::CROSSED_OUT),
"<del> must be line-through"
);
for word in ["3", "4", "5"] {
assert!(
tl.layout[find(word)].style.contains(FontStyle::ITALIC),
"<cite>/<dfn>/<var> must be italic ({word})"
);
}
}
#[test]
fn test_layout_strong_font_weight_normal_disables_bold() {
init();
let html = "<p><strong>word</strong></p>";
let css = "strong { font-weight: normal; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx = tl
.layout
.iter()
.position(|b| b.word == "w")
.expect("strong text must render");
assert!(
!tl.layout[idx].style.contains(FontStyle::BOLD),
"`strong {{ font-weight: normal }}` must suppress the UA bold (got {:?})",
tl.layout[idx].style
);
}
#[test]
fn test_layout_code_kbd_samp_render_plain() {
init();
let html = "<p><code>fn main()</code> <kbd>ctrl</kbd> <samp>out</samp></p>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let canvas: String = tl.layout.iter().map(|b| b.word.as_str()).collect();
assert!(canvas.contains("fn main()"), "code text must render");
assert!(canvas.contains("ctrl"), "kbd text must render");
assert!(canvas.contains("out"), "samp text must render");
}
#[test]
fn test_layout_code_css_color_applies() {
init();
let html = "<p><code>x</code></p>";
let css = "code { color: magenta; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let idx = tl
.layout
.iter()
.position(|b| b.word == "x")
.expect("code text must render");
assert_eq!(
tl.layout[idx].fcolor,
Some(Color::Rgb {
r: 255,
g: 0,
b: 255
}),
"code {{ color: magenta }} must paint the cell magenta"
);
}
#[test]
fn test_layout_br_forces_line_break() {
init();
let html = "<p>aaa<br>bbb</p>";
let css = "";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let a_idx = tl
.layout
.iter()
.position(|b| b.word == "a")
.expect("pre-br text must render");
let b_idx = tl
.layout
.iter()
.position(|b| b.word == "b")
.expect("post-br text must render");
assert!(b_idx / cols > a_idx / cols, "<br> must break the line");
assert_eq!(
b_idx % cols,
a_idx % cols,
"post-br text must start at the same column"
);
}
#[test]
fn test_body_background_fills_canvas() {
init();
let html = "<body><h1>Hi</h1></body>";
let css = "body { background-color: blue; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let rows = tl.layout.len() / cols;
let last_row = &tl.layout[(rows - 1) * cols];
assert_eq!(
last_row.bcolor,
Some(Color::Rgb { r: 0, g: 0, b: 255 }),
"body background must fill rows below the content"
);
assert_eq!(last_row.word, " ", "the fill must not add glyphs");
}
#[test]
fn test_html_background_wins_over_body_for_canvas() {
init();
let html = "<body><p>x</p></body>";
let css = "html { background-color: green; } body { background-color: red; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let rows = tl.layout.len() / cols;
let cell = &tl.layout[(rows - 1) * cols];
assert_eq!(
cell.bcolor,
Some(Color::Rgb { r: 0, g: 128, b: 0 }),
"<html> background must win over <body> for the canvas"
);
}
#[test]
fn test_hsl_background_through_cascade_and_paint() {
init();
let html = "<body><p>x</p></body>";
let css = "body { background: hsl(240, 100%, 50%); }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let rows = tl.layout.len() / cols;
let cell = &tl.layout[(rows - 1) * cols];
assert_eq!(
cell.bcolor,
Some(Color::Rgb { r: 0, g: 0, b: 255 }),
"hsl() through the `background` shorthand must paint the canvas"
);
}
#[test]
fn test_linear_gradient_background_paints_each_cell() {
init();
let html = "<div id='gradient'>ok</div>";
let css = "#gradient { width: 20ch; height: 2; \
background: linear-gradient(to right, red, blue); }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let first = tl.layout[0].bcolor.expect("gradient start color");
let last = tl.layout[19].bcolor.expect("gradient end color");
assert_ne!(first, last);
assert!(
matches!(first, Color::Rgb { r, b, .. } if r > b),
"the left edge should lean red, got {first:?}"
);
assert!(
matches!(last, Color::Rgb { r, b, .. } if b > r),
"the right edge should lean blue, got {last:?}"
);
assert!(tl.layout[..20].iter().all(|cell| cell.bcolor.is_some()));
}
#[test]
fn test_child_preserves_gradient_origin_from_parent() {
init();
let html = "<div id='gradient'><div>one</div><div>two</div></div>";
let css = "#gradient { width: 20ch; height: 4ch; \
background: linear-gradient(to bottom, red, blue); } \
#gradient > div { height: 1ch; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let cols = term_map.columns as usize;
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let first = tl.layout[0].bcolor.expect("first child background");
let second = tl.layout[cols].bcolor.expect("second child background");
assert!(
matches!((first, second), (
Color::Rgb { b: first_b, .. },
Color::Rgb { b: second_b, .. }
) if second_b > first_b),
"child fills must continue the parent gradient: {first:?} -> {second:?}"
);
}
#[test]
fn test_background_clip_text_paints_gradient_glyphs() {
init();
let html = "<div id='text'>AB</div>";
let css = "#text { width: 2ch; color: transparent; \
background: linear-gradient(to right, red, blue); \
background-clip: text; }";
let (root_id, arena) = setup(html, css);
let term_map = mock_term_map();
let mut tl = TermLayout::new(&term_map, 1.0);
tl.draw(&arena, root_id, &term_map);
let first = tl.layout[0].fcolor.expect("first glyph color");
let second = tl.layout[1].fcolor.expect("second glyph color");
assert!(
matches!(first, Color::Rgb { r, b, .. } if r > b)
&& matches!(second, Color::Rgb { r, b, .. } if b > r),
"text gradient should progress from red to blue: {first:?} -> {second:?}"
);
assert!(
tl.layout[..2].iter().all(|cell| cell.bcolor.is_none()),
"background-clip: text must not paint the box background"
);
}
#[test]
fn example_spectrum_stays_inside_sidebar_scroll_area() {
init();
let html = include_str!("../../../example/src/player.html")
.replace(
"<div id=\"music-side\" class=\"side\" style=\"display:none\"",
"<div id=\"music-side\" class=\"side\"",
)
.replace(
"<div id=\"viz-l\" class=\"viz-pre\"> </div>",
"<div id=\"viz-l\" class=\"viz-pre\">1▁2▂3▃4▄</div>",
)
.replace(
"<div id=\"viz-r\" class=\"viz-pre\"> </div>",
"<div id=\"viz-r\" class=\"viz-pre\">8█7▇6▆5▅</div>",
);
let mut html_parser = html_parse::Parser::new(&html);
let (root_id, base_arena, embedded_css) = html_parser.parse();
for (cols, rows) in [(100, 30), (80, 24), (60, 20), (50, 20), (40, 20)] {
let mut arena = base_arena.clone();
let mut css_parser = css_parse::Parser::new(&embedded_css);
let rules = css_parser.parse();
css_parse::css_into_node_with_state(
&mut arena,
root_id,
&rules,
&css_parse::DynamicState {
viewport: css_parse::Viewport { cols, rows },
..Default::default()
},
);
let term_map = TermMap {
columns: cols as u16,
rows: rows as u16,
};
let mut tl = TermLayout::new(&term_map, 1.0);
let boxes = tl.draw(&arena, root_id, &term_map);
fn find<'a>(
arena: &NodeArena,
boxes: &'a [htui::layout::LayoutBox],
class_name: &str,
) -> Option<&'a htui::layout::LayoutBox> {
for lb in boxes {
let class = arena[lb.node_id]
.attributes
.get("class")
.map(String::as_str)
.unwrap_or("");
if class.split_whitespace().any(|part| part == class_name) {
return Some(lb);
}
if let Some(found) = find(arena, &lb.children, class_name) {
return Some(found);
}
}
None
}
let side = find(&arena, &boxes, "side").expect("sidebar exists");
let scroll = find(&arena, &boxes, "side-scroll").expect("sidebar scroll exists");
let spectrum = find(&arena, &boxes, "spectrum-card").expect("spectrum card exists");
let viz = find(&arena, &boxes, "viz-pre").expect("spectrum bars exist");
assert!(
scroll.rect.y.saturating_add(scroll.rect.height)
<= side.rect.y.saturating_add(side.rect.height),
"{cols}x{rows}: sidebar scroll must stay inside the sidebar"
);
assert!(
spectrum.rect.y >= scroll.rect.y
&& spectrum.rect.y < scroll.rect.y.saturating_add(scroll.rect.height),
"{cols}x{rows}: spectrum card must start inside the scroll viewport"
);
assert!(
viz.rect.x.saturating_add(viz.rect.width)
<= scroll.rect.x.saturating_add(scroll.rect.width),
"{cols}x{rows}: spectrum must not paint outside the scroll area"
);
}
}