//! What the parser makes of a document, and what survives being laid out. use guiduck_core::text::TextContext; use guiduck_scene::geom::{Point, Size}; use super::*; const VIEW_WIDTH: f64 = 200.0; /// A view laid out at `width`, in the sample font, exactly as the tree would /// lay it out. fn view(source: &str, width: f64) -> (MarkdownView, TextContext) { let mut text_cx = guiduck_samples::sample_text_context(); let mut view = MarkdownView::new(source); let _ = view.take_dirt(); view.finalize_layout(&mut text_cx, Size::new(width, 0.0), Size::ZERO); (view, text_cx) } fn kinds(view: &MarkdownView) -> Vec { view.blocks().iter().map(|block| block.kind).collect() } const DOC: &str = "\ # Title Some prose with a [link](home) in it. ## Sub *head* ``` fn main() {} ``` - one - two 1. first 2. second > quoted prose > still quoted --- Last. "; #[test] fn every_block_kind_in_scope_parses() { let (view, _cx) = view(DOC, VIEW_WIDTH); assert_eq!( kinds(&view), vec![ BlockKind::Heading(1), BlockKind::Paragraph, BlockKind::Heading(2), BlockKind::Code, BlockKind::ListItem, BlockKind::ListItem, BlockKind::ListItem, BlockKind::ListItem, BlockKind::Paragraph, BlockKind::Rule, BlockKind::Paragraph, ], ); } #[test] fn a_heading_carries_its_level_and_its_size() { let (view, _cx) = view(DOC, VIEW_WIDTH); let blocks = view.blocks(); let h1 = blocks[0].body.as_ref().expect("a heading has text"); let h2 = blocks[2].body.as_ref().expect("a heading has text"); assert_eq!(h1.text(), "Title"); assert_eq!(h2.text(), "Sub head", "the emphasis is a span, not a glyph"); assert!( h1.font_size() > h2.font_size(), "the type scale came from the tokens, in level order", ); assert!( h2.font_size() > blocks[1].body.as_ref().unwrap().font_size(), "and a sub-head is still bigger than body prose", ); } #[test] fn a_code_block_keeps_its_lines_and_its_own_font() { let (view, _cx) = view(DOC, VIEW_WIDTH); let code = view.blocks()[3].body.as_ref().expect("code has text"); assert_eq!(code.text(), "fn main() {}", "the fence is not the program"); } #[test] fn a_list_numbers_itself_and_hangs_its_markers() { let (view, _cx) = view(DOC, VIEW_WIDTH); let blocks = view.blocks(); let markers: Vec<&str> = blocks .iter() .filter(|block| block.kind == BlockKind::ListItem) .map(|block| block.marker.as_ref().expect("an item has a marker").text()) .collect(); assert_eq!(markers, vec!["\u{2022}", "\u{2022}", "1.", "2."]); let item = blocks .iter() .find(|block| block.kind == BlockKind::ListItem) .unwrap(); assert_eq!(item.list_depth, 1); assert!( item.marker_origin.x < item.body_origin.x, "the marker sits in the gutter the body's indent opened", ); } #[test] fn a_block_quote_carries_its_depth_and_indents_for_it() { let (view, _cx) = view(DOC, VIEW_WIDTH); let quote = view.blocks()[8].body.as_ref().expect("quoted prose"); assert_eq!( quote.text(), "quoted prose still quoted", "a soft break inside a quote is a space, as it is anywhere else", ); assert_eq!(view.blocks()[8].quote_depth, 1); assert!(view.blocks()[8].indent > 0.0); assert_eq!(view.blocks()[1].quote_depth, 0, "prose is not quoted"); } #[test] fn nested_quotes_and_lists_count_their_levels() { let (view, _cx) = view("> > deep\n\n- a\n - b\n", VIEW_WIDTH); let blocks = view.blocks(); assert_eq!(blocks[0].quote_depth, 2); assert_eq!(blocks[1].list_depth, 1, "the outer item"); assert_eq!(blocks[2].list_depth, 2, "the nested one"); assert!(blocks[2].indent > blocks[1].indent); } #[test] fn a_thematic_break_has_no_text_at_all() { let (view, _cx) = view(DOC, VIEW_WIDTH); let rule = &view.blocks()[9]; assert_eq!(rule.kind, BlockKind::Rule); assert!(rule.body.is_none(), "a break has nothing to say"); assert!(rule.height > 0.0, "but it is still on the page"); } #[test] fn blocks_stack_without_overlapping() { let (view, _cx) = view(DOC, VIEW_WIDTH); for pair in view.blocks().windows(2) { assert!( pair[1].top >= pair[0].top + pair[0].height, "{:?} overlaps {:?}", pair[1].kind, pair[0].kind, ); } } const PROSE: &str = "Read the [documentation for widgets](guide) before you \ start, or ask on the [forum](forum)."; /// The claim the whole design rests on: an inline link is a byte range of the /// block's prose, a line break does not move a byte, and so a link the wrap /// splits is still one link — hittable on both of the lines it landed on. #[test] fn a_links_byte_range_survives_wrapping() { // Narrow enough that "documentation for widgets" cannot fit on one line. let (view, _cx) = view(PROSE, 120.0); let block = &view.blocks()[0]; let body = block.body.as_ref().expect("prose"); assert!(body.line_count() > 1, "the paragraph wrapped"); let text = body.text(); let start = text.find("documentation").expect("the link's first word"); let end = text.find("widgets").expect("the link's last word") + "widgets".len(); let point_on = |byte: usize| { body.byte_bounds(byte) .expect("the byte is laid out") .center() + block.body_origin.to_vec2() }; let first = point_on(start); let last = point_on(end - 1); assert_ne!( first.y, last.y, "the link's own words landed on different lines", ); assert_eq!(view.link_at(first), Some("guide"), "the first line's half"); assert_eq!(view.link_at(last), Some("guide"), "the second line's half"); // A second link in the same paragraph is its own answer, and prose between // them is nobody's. let forum = text.find("forum").expect("the second link"); assert_eq!(view.link_at(point_on(forum)), Some("forum")); assert_eq!(view.link_at(point_on(0)), None, "\"Read\" is not a link"); } /// A link in a *later* block is found in that block's coordinates, which is the /// one thing stacking paragraphs adds over a `Text`. #[test] fn a_link_in_a_later_block_is_hit_in_its_own_coordinates() { let (view, _cx) = view("# Title\n\nSee the [guide](guide).\n", 400.0); let block = &view.blocks()[1]; assert!(block.top > 0.0, "the paragraph is below the heading"); let body = block.body.as_ref().unwrap(); let byte = body.text().find("guide").unwrap(); let point = body.byte_bounds(byte).unwrap().center() + block.body_origin.to_vec2(); assert_eq!(view.link_at(point), Some("guide")); assert_eq!( view.link_at(Point::new(point.x, 0.0)), None, "the same x in the heading's row is not the link", ); } #[test] fn clicking_a_link_reports_its_target_as_on_link() { let (mut view, mut text_cx) = view(PROSE, 400.0); let block = &view.blocks()[0]; let body = block.body.as_ref().unwrap(); let byte = body.text().find("forum").unwrap(); let at = body.byte_bounds(byte).unwrap().center() + block.body_origin.to_vec2(); let event = PointerEvent { window: at, local: at, button: Some(PointerButton::Left), click_count: 1, }; // A link follows on the *confirmed* single click (the settled burst), not // the immediate one — so a double-click on it does not navigate. let consumed = view.on_pointer( EventKind::CountedClick, &event, &mut text_cx, &mut guiduck_core::NoClipboard, ); assert!(consumed, "a confirmed single click on a link is the link's"); assert_eq!( view.take_emitted(), vec![EventData::User { name: "on-link".to_owned(), payload: Some(UserValue::Str("forum".to_owned())), }], ); assert_eq!( view.cursor(at), guiduck_core::CursorShape::Pointer, "and the hand said so first", ); } /// Every link is a node an assistive technology can find, name, and click — /// across blocks, each in its own place on the page. #[test] fn every_link_is_announced_and_actionable() { let (mut view, mut text_cx) = view( "# T\n\nSee the [guide](guide) or the [forum](forum).", 400.0, ); let mut node = accesskit::Node::new(accesskit::Role::Document); let mut update = accesskit::TreeUpdate { nodes: Vec::new(), tree: None, tree_id: accesskit::TreeId::ROOT, focus: accesskit::NodeId(0), }; let mut counter = 1_u64; let mut next_id = || { let id = accesskit::NodeId(counter); counter += 1; id }; view.accessibility_extended( &mut node, &mut update, &mut next_id, Point::ORIGIN, &mut text_cx, ); assert_eq!(node.children().len(), 2, "a node per link"); let (id, link) = &update.nodes[0]; assert_eq!(link.role(), accesskit::Role::Link); assert_eq!(link.label(), Some("guide")); assert_eq!(link.url(), Some("guide")); let bounds = link.bounds().expect("a link is somewhere on screen"); assert!( bounds.y0 > 0.0, "and its place is the block's, not the paragraph's own origin", ); view.accessibility_action(*id, accesskit::Action::Click); assert_eq!( view.take_emitted(), vec![EventData::User { name: "on-link".to_owned(), payload: Some(UserValue::Str("guide".to_owned())), }], ); } const TABLE: &str = "\ | Name | Score | | :--- | -----: | | Alice | 10 | | Bob | [profile](bob) | "; #[test] fn a_table_parses_into_an_aligned_grid() { let (view, _cx) = view(TABLE, 300.0); let blocks = view.blocks(); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].kind, BlockKind::Table); let table = blocks[0].table.as_ref().expect("a table block has a grid"); assert_eq!(table.columns(), 2); assert_eq!(table.rows.len(), 3, "the header row plus two body rows"); assert_eq!(table.header_rows, 1); assert_eq!(table.rows[0][0].body.text(), "Name"); assert_eq!(table.rows[0][1].body.text(), "Score"); assert_eq!(table.rows[1][0].body.text(), "Alice"); assert_eq!( table.aligns, vec![CellAlign::Start, CellAlign::End], "the delimiter row's colons set the column alignment", ); } #[test] fn a_table_places_a_non_overlapping_grid() { let (view, _cx) = view(TABLE, 300.0); let table = view.blocks()[0].table.as_ref().unwrap(); assert_eq!(table.col_edges.len(), 3, "two columns are three boundaries"); assert_eq!(table.row_edges.len(), 4, "three rows are four boundaries"); assert!( table.col_edges.windows(2).all(|edge| edge[1] > edge[0]), "columns advance left to right", ); assert!( table.row_edges.windows(2).all(|edge| edge[1] > edge[0]), "rows advance top to bottom", ); for (r, row) in table.rows.iter().enumerate() { for (c, cell) in row.iter().enumerate() { assert!( cell.origin.x >= table.col_edges[c] && cell.origin.x < table.col_edges[c + 1], "cell ({r},{c}) sits inside its column", ); assert!( cell.origin.y >= table.row_edges[r] && cell.origin.y < table.row_edges[r + 1], "cell ({r},{c}) sits inside its row", ); } } } #[test] fn an_end_aligned_cell_is_pushed_right() { // The Score column is right-aligned, and "10" is narrower than the column, // so it sits past a start-aligned cell would. let (view, _cx) = view(TABLE, 300.0); let table = view.blocks()[0].table.as_ref().unwrap(); let start_cell = &table.rows[1][0]; // "Alice", left-aligned let end_cell = &table.rows[1][1]; // "10", right-aligned let end_col_left = table.col_edges[1]; assert!( end_cell.origin.x - end_col_left > start_cell.origin.x - table.col_edges[0], "the right-aligned cell's text is inset further into its column", ); } #[test] fn a_link_in_a_cell_is_hittable() { let (view, _cx) = view(TABLE, 300.0); let table = view.blocks()[0].table.as_ref().unwrap(); let cell = &table.rows[2][1]; assert_eq!(cell.body.text(), "profile"); // A point on the cell's link, in the widget's coordinates: the same // link_at a whole paragraph answers, reached through the grid. let local = cell .body .byte_bounds(0) .expect("the link's first byte is laid out") .center() + cell.origin.to_vec2(); assert_eq!(view.link_at(local), Some("bob")); } #[test] fn plain_text_reads_a_table_by_rows() { let (view, _cx) = view(TABLE, 300.0); let text = view.plain_text(); assert!( text.contains("Name\tScore"), "header, tab-separated: {text:?}" ); assert!(text.contains("Alice\t10")); assert!(text.contains("Bob\tprofile")); } #[test] fn a_table_does_not_overlap_its_neighbours() { let (view, _cx) = view("before\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nafter", 300.0); let kinds = kinds(&view); assert_eq!( kinds, vec![BlockKind::Paragraph, BlockKind::Table, BlockKind::Paragraph], ); for pair in view.blocks().windows(2) { assert!( pair[1].top >= pair[0].top + pair[0].height, "{:?} overlaps {:?}", pair[1].kind, pair[0].kind, ); } } /// A solid `width`×`height` image, so a test resolver can hand the widget real /// pixels without a file on disk. fn solid_image(width: u32, height: u32) -> guiduck_scene::paint::ImageBrush { use guiduck_scene::paint::{Blob, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; ImageBrush::from(ImageData { data: Blob::new(std::sync::Arc::new(vec![ 0xff_u8; (width * height * 4) as usize ])), format: ImageFormat::Rgba8, alpha_type: ImageAlphaType::Alpha, width, height, }) } /// A resolved image of the given size. fn resolved(width: u32, height: u32) -> ImageResult { ImageResult::Graphic(Graphic::Image(solid_image(width, height))) } /// Build and lay out a view with an image resolver set before layout. fn view_with_images( source: &str, width: f64, resolver: impl Fn(&str) -> ImageResult + 'static, ) -> (MarkdownView, TextContext) { let mut text_cx = guiduck_samples::sample_text_context(); let mut view = MarkdownView::new(source); view.set_image_resolver(resolver); let _ = view.take_dirt(); view.finalize_layout(&mut text_cx, Size::new(width, 0.0), Size::ZERO); (view, text_cx) } #[test] fn a_lone_image_is_a_block_the_app_resolves() { let (view, _cx) = view_with_images("![a diagram](diagram.png)", 200.0, |src| { if src == "diagram.png" { resolved(40, 20) } else { ImageResult::None } }); let blocks = view.blocks(); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].kind, BlockKind::Image); let image = blocks[0] .image .as_ref() .expect("an image block has content"); assert_eq!(image.src, "diagram.png"); assert!(image.graphic.is_some(), "the resolver supplied the pixels"); assert!( (blocks[0].height - 20.0).abs() < 1.0, "the block is the image's natural height, got {}", blocks[0].height, ); } #[test] fn a_wide_image_is_scaled_to_the_width() { // 400×200 (2:1) in a 100px view → 100 wide, 50 tall, aspect kept. let (view, _cx) = view_with_images("![big](big.png)", 100.0, |_| resolved(400, 200)); assert!( (view.blocks()[0].height - 50.0).abs() < 1.0, "got {}", view.blocks()[0].height, ); } #[test] fn an_unresolved_image_falls_back_to_its_alt() { // No resolver at all. let (view, _cx) = view("![the alt text](missing.png)", 200.0); let blocks = view.blocks(); assert_eq!(blocks[0].kind, BlockKind::Image); let image = blocks[0].image.as_ref().unwrap(); assert!(image.graphic.is_none()); assert_eq!(image.alt.text(), "the alt text"); assert!(blocks[0].height > 0.0, "the alt text still takes space"); assert!( view.plain_text().contains("the alt text"), "and is read out" ); } #[test] fn a_declined_image_keeps_its_alt() { let (view, _cx) = view_with_images("![alt](x)", 200.0, |_| ImageResult::None); let image = view.blocks()[0].image.as_ref().unwrap(); assert!( image.graphic.is_none(), "the resolver declining leaves the alt" ); assert!(!image.deferred, "None is final, not pending"); assert_eq!(image.alt.text(), "alt"); } #[test] fn a_deferred_image_is_polled_until_it_arrives() { use std::cell::Cell; use std::rc::Rc; // The resolver reports the image as loading until the app's "fetch" // completes, then hands it over. let ready = Rc::new(Cell::new(false)); let seen = ready.clone(); let (mut view, _cx) = view_with_images("![loading](slow)", 200.0, move |_| { if seen.get() { resolved(30, 20) } else { ImageResult::DeferredImage } }); let image = view.blocks()[0].image.as_ref().unwrap(); assert!(image.deferred, "the alt stands in while it loads"); assert!(image.graphic.is_none()); assert!( guiduck_core::Widget::take_wake(&mut view).is_some(), "a deferred image schedules a re-ask", ); // The fetch completes; the next poll picks it up and stops asking. ready.set(true); view.on_timer(); let image = view.blocks()[0].image.as_ref().unwrap(); assert!(image.graphic.is_some(), "the deferred image resolved"); assert!(!image.deferred); assert!( guiduck_core::Widget::take_wake(&mut view).is_none(), "and the poll stops once nothing is loading", ); } #[test] fn an_inline_image_renders_as_its_alt_text() { // An image mixed with prose is not a block image; its alt stands in the // sentence. let (view, _cx) = view("see ![icon](i.png) here", 300.0); let blocks = view.blocks(); assert_eq!(blocks.len(), 1); assert_eq!( blocks[0].kind, BlockKind::Paragraph, "not lifted to a block" ); assert_eq!(blocks[0].body.as_ref().unwrap().text(), "see icon here"); }