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;
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
}
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);
}
}
}
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,
}
}
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();
}
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()
}
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));
let text_layout = lw.layout_results.get(&dom_id).and_then(|result| {
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());
}
}
}
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
});
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)
};
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);
}
fn type_text(&mut self, text: &str) -> (usize, String, String) {
let lw = self.layout_window.as_mut().unwrap();
let affected = lw.record_text_input(text);
let affected_count = affected.len();
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()),
};
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)
}
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()
}
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
}
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 { .. }))
}
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);
}
}
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)
}
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()
}
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
}
}
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]
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");
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);
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);
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
);
assert!(
total_glyphs >= 11,
"Expected at least 11 glyphs for 'Hello World', got {}",
total_glyphs
);
assert!(
h.get_cursor_byte_offset().is_none(),
"Cursor should be None before focus"
);
}
#[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");
let dl_before = h.clone_display_list();
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);
let focused = h.get_focused_node();
assert!(focused.is_some(), "Focus should be set after focus_node()");
eprintln!(" [verify] Focused: {:?}", focused);
let glyphs_before = h.count_text_glyphs();
let total_glyphs_before: usize = glyphs_before.iter().map(|(_, c)| c).sum();
let (affected, old_text, inserted) = h.type_text("X");
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'");
let cursor_after = h.get_cursor_byte_offset();
eprintln!(" [verify] Cursor byte offset after input: {:?}", cursor_after);
assert!(cursor_after.is_some(), "Cursor should exist after text input");
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
);
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());
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);
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]
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");
let ce_nodes = h.find_contenteditable_nodes();
let dom_id = DomId { inner: 0 };
h.focus_node(dom_id, ce_nodes[0]);
let (n1, _, _) = h.type_text("1");
let frame1 = h.render();
save_screenshot(&frame1, "03b_after_typing_1");
let (_n2, _, _) = h.type_text("2");
let frame2 = h.render();
save_screenshot(&frame2, "03c_after_typing_2");
let (_n3, _, _) = h.type_text("3");
let frame3 = h.render();
save_screenshot(&frame3, "03d_after_typing_3");
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");
}
#[test]
fn contenteditable_damage_detection() {
let mut h = ContentEditableHarness::new(400.0, 300.0);
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();
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();
let damage = cpurender::compute_display_list_damage(&dl_before, &dl_after);
eprintln!(" [verify] Damage rects: {:?}", damage);
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
);
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]
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);
h.focus_node(DomId { inner: 0 }, ce_nodes[0]);
h.type_text("!");
let frame1 = h.render();
save_screenshot(&frame1, "05b_after_typing_in_editor1");
h.focus_node(DomId { inner: 0 }, ce_nodes[1]);
h.type_text("?");
let frame2 = h.render();
save_screenshot(&frame2, "05c_after_typing_in_editor2");
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]
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();
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();
let full_render = h.render();
save_screenshot(&full_render, "06a_full_render");
let damage = cpurender::compute_display_list_damage(&dl_before, &dl_after);
eprintln!(" [verify] Damage result: {:?}", damage.as_ref().map(|r| r.len()));
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]
fn contenteditable_overflow_wraps_at_end_not_start() {
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);
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");
let ce_nodes = h.find_contenteditable_nodes();
assert!(!ce_nodes.is_empty());
h.focus_node(DomId { inner: 0 }, ce_nodes[0]);
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");
let lw = h.layout_window.as_ref().unwrap();
let dom_id = DomId { inner: 0 };
let layout_result = lw.layout_results.get(&dom_id).unwrap();
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");
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());
}
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,
);
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());
}