H
horizon-browser
↓ 0 downloadsHorizon: a real box-model HTML/CSS layout engine with a Canvas 2D surface, no_std + alloc, zero dependencies -- LingOS's next-generation browser engine, sibling to bring-browser
Keywords browser, html, css, layout, canvas, no_std, lingos
lingfu install horizon-browserCode / Files (9)
src/layout.rs
//! Real box-model layout: block boxes stack in normal flow (margin,
//! padding, explicit-or-auto width, no floats/flex/grid/position yet);
//! inline content (text + `a`/`b`/`i`/`span`) flows and wraps within its
//! block's content width, assuming a monospace glyph grid -- the embedder
//! passes its actual font metrics (`char_w`, `line_h`) in pixels, the same
//! spirit as `bring-browser` taking a `cols` parameter, so this stays
//! font-agnostic.
//!
//! Known v0.1 simplification: adjacent inline elements always separate on
//! a single implicit space UNLESS the source text touches with no
//! whitespace at all (tracked via the `Glue` token) -- e.g. `foo<b>bar</b>`
//! renders as `foobar`, `foo <b>bar</b>` renders as `foo bar`. Margins do
//! not collapse between siblings.
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use crate::canvas::CanvasSurface;
use crate::css::{Display, Style};
use crate::dom::{Dom, Node, Tag};
use crate::{DisplayItem, ImgRef, Link};
#[derive(Clone, Copy)]
pub struct Metrics {
pub char_w: u32,
pub line_h: u32,
}
pub struct LayoutOut {
pub links: Vec<Link>,
pub images: Vec<ImgRef>,
pub canvases: Vec<CanvasSurface>,
pub canvas_ids: Vec<String>,
}
pub struct LayoutResult {
pub items: Vec<DisplayItem>,
pub links: Vec<Link>,
pub images: Vec<ImgRef>,
pub canvases: Vec<CanvasSurface>,
pub canvas_ids: Vec<String>,
pub height: u32,
pub background: Option<u32>,
}
pub fn layout_document(dom: &Dom, styles: &[Style], viewport_w: u32, metrics: Metrics) -> LayoutResult {
let mut ctx = LayoutOut { links: Vec::new(), images: Vec::new(), canvases: Vec::new(), canvas_ids: Vec::new() };
let body = find_first(dom, dom.root, Tag::Body).unwrap_or(dom.root);
let (height, items) = layout_node(dom, styles, body, 0, 0, viewport_w, metrics, &mut ctx);
LayoutResult {
items,
links: ctx.links,
images: ctx.images,
canvases: ctx.canvases,
canvas_ids: ctx.canvas_ids,
height,
background: styles[body].background,
}
}
fn find_first(dom: &Dom, idx: usize, tag: Tag) -> Option<usize> {
if dom.nodes[idx].tag == tag {
return Some(idx);
}
for &c in &dom.nodes[idx].children {
if let Some(f) = find_first(dom, c, tag) {
return Some(f);
}
}
None
}
fn is_heading(tag: Tag) -> bool {
matches!(tag, Tag::H1 | Tag::H2 | Tag::H3 | Tag::H4 | Tag::H5 | Tag::H6)
}
/// Lay out one block-level (or special leaf) node at `(x, y)` within
/// `avail_w` pixels of available width. Returns the total vertical space
/// consumed (including this box's own top+bottom margin) and its painted
/// display items, in paint order (background first).
fn layout_node(dom: &Dom, styles: &[Style], idx: usize, x: u32, y: u32, avail_w: u32, metrics: Metrics, ctx: &mut LayoutOut) -> (u32, Vec<DisplayItem>) {
let node = &dom.nodes[idx];
let style = &styles[idx];
if style.display == Display::None {
return (0, Vec::new());
}
match node.tag {
Tag::Img => return layout_img(node, style, x, y, avail_w, metrics, ctx),
Tag::Canvas => return layout_canvas(node, style, x, y, avail_w, ctx),
Tag::Head | Tag::Title | Tag::Style | Tag::Script => return (0, Vec::new()),
_ => {},
}
let margin = style.margin;
let padding = style.padding;
let box_x = x + margin;
let box_y = y + margin;
let content_w = style
.width
.unwrap_or_else(|| avail_w.saturating_sub(margin * 2).saturating_sub(padding * 2))
.max(metrics.char_w);
let inner_x = box_x + padding;
let content_y = box_y + padding;
let mut items: Vec<DisplayItem> = Vec::new();
let mut cy = content_y;
let mut pending_tokens: Vec<InlineToken> = Vec::new();
let mut last_ends_ws = true;
let li_indent = if node.tag == Tag::Li { metrics.char_w * 2 } else { 0 };
let heading = is_heading(node.tag);
if node.tag == Tag::Li {
let bullet_color = style.color.unwrap_or(0x808080);
items.push(DisplayItem::Rect { x: inner_x, y: cy + metrics.line_h / 2, w: 4, h: 4, color: bullet_color });
}
for &child in &node.children {
let cnode = &dom.nodes[child];
let cstyle = &styles[child];
if cstyle.display == Display::None {
continue;
}
let is_inline = cstyle.display == Display::Inline || cnode.tag == Tag::Text;
if is_inline {
flatten_inline(dom, styles, child, style.color, style.bold, None, heading, &mut last_ends_ws, &mut pending_tokens, ctx);
} else {
flush_pending_inline(&mut pending_tokens, &mut items, &mut cy, &mut last_ends_ws, inner_x, li_indent, content_w, metrics);
let (h, mut sub) = layout_node(dom, styles, child, inner_x, cy, content_w, metrics, ctx);
items.append(&mut sub);
cy += h;
}
}
flush_pending_inline(&mut pending_tokens, &mut items, &mut cy, &mut last_ends_ws, inner_x, li_indent, content_w, metrics);
let natural_h = (cy - content_y) + padding;
let box_h = style.height.unwrap_or(natural_h);
let box_w = content_w + padding * 2;
let mut out_items = Vec::with_capacity(items.len() + 1);
if let Some(bg) = style.background {
out_items.push(DisplayItem::Rect { x: box_x, y: box_y, w: box_w, h: box_h, color: bg });
}
out_items.extend(items);
(margin * 2 + box_h, out_items)
}
fn flush_pending_inline(
pending_tokens: &mut Vec<InlineToken>,
items: &mut Vec<DisplayItem>,
cy: &mut u32,
last_ends_ws: &mut bool,
inner_x: u32,
li_indent: u32,
content_w: u32,
metrics: Metrics,
) {
if pending_tokens.is_empty() {
return;
}
let (h, mut sub) = layout_inline_tokens(pending_tokens, inner_x + li_indent, *cy, content_w.saturating_sub(li_indent).max(metrics.char_w), metrics);
items.append(&mut sub);
*cy += h;
pending_tokens.clear();
*last_ends_ws = true;
}
fn layout_img(node: &Node, style: &Style, x: u32, y: u32, avail_w: u32, metrics: Metrics, ctx: &mut LayoutOut) -> (u32, Vec<DisplayItem>) {
let margin = style.margin;
let box_x = x + margin;
let box_y = y + margin;
let w = style.width.unwrap_or(metrics.char_w * 20).min(avail_w.saturating_sub(margin * 2).max(1));
let h = style.height.unwrap_or(metrics.line_h * 6);
let src = node.attr("src").unwrap_or("").to_string();
let image_idx = ctx.images.len();
ctx.images.push(ImgRef { src });
(margin * 2 + h, vec![DisplayItem::Image { x: box_x, y: box_y, w, h, image: image_idx }])
}
fn layout_canvas(node: &Node, style: &Style, x: u32, y: u32, avail_w: u32, ctx: &mut LayoutOut) -> (u32, Vec<DisplayItem>) {
let margin = style.margin;
let box_x = x + margin;
let box_y = y + margin;
let w = node
.attr("width")
.and_then(|v| v.parse::<u32>().ok())
.or(style.width)
.unwrap_or(300)
.min(avail_w.saturating_sub(margin * 2).max(1));
let h = node.attr("height").and_then(|v| v.parse::<u32>().ok()).or(style.height).unwrap_or(150);
let id = node.attr("id").unwrap_or("").to_string();
let canvas_idx = ctx.canvases.len();
ctx.canvases.push(CanvasSurface::new(w, h));
ctx.canvas_ids.push(id);
(margin * 2 + h, vec![DisplayItem::Canvas { x: box_x, y: box_y, w, h, canvas: canvas_idx }])
}
// ── Inline flow ──────────────────────────────────────────────────────────
enum InlineToken {
Word { text: String, color: Option<u32>, bold: bool, heading: bool, link: Option<u16> },
/// Marks that the following Word has no space before it (source text
/// touched with no whitespace across an inline-element boundary).
Glue,
Break,
}
#[allow(clippy::too_many_arguments)]
fn flatten_inline(
dom: &Dom,
styles: &[Style],
idx: usize,
color: Option<u32>,
bold: bool,
link: Option<u16>,
heading: bool,
last_ends_ws: &mut bool,
out: &mut Vec<InlineToken>,
ctx: &mut LayoutOut,
) {
let node = &dom.nodes[idx];
match node.tag {
Tag::Text => {
let t = &node.text;
if t.is_empty() {
return;
}
let starts_ws = t.starts_with(' ');
let ends_ws = t.ends_with(' ') || t.trim().is_empty();
let mut first = true;
for w in t.split_ascii_whitespace() {
let glue = first && !starts_ws && !*last_ends_ws;
if glue {
out.push(InlineToken::Glue);
}
out.push(InlineToken::Word { text: w.to_string(), color, bold, heading, link });
first = false;
}
*last_ends_ws = ends_ws;
},
Tag::Br => {
out.push(InlineToken::Break);
*last_ends_ws = true;
},
Tag::A => {
let href = node.attr("href").unwrap_or("").to_string();
let link_idx = if !href.is_empty() {
ctx.links.push(Link { href });
Some((ctx.links.len() - 1) as u16)
} else {
link
};
let c = styles[idx].color.or(color);
let bd = styles[idx].bold || bold;
for &ch in &node.children {
flatten_inline(dom, styles, ch, c, bd, link_idx, heading, last_ends_ws, out, ctx);
}
},
Tag::B | Tag::I | Tag::Span => {
let c = styles[idx].color.or(color);
let bd = styles[idx].bold || bold;
for &ch in &node.children {
flatten_inline(dom, styles, ch, c, bd, link, heading, last_ends_ws, out, ctx);
}
},
_ => {
for &ch in &node.children {
flatten_inline(dom, styles, ch, color, bold, link, heading, last_ends_ws, out, ctx);
}
},
}
}
fn flush_run(items: &mut Vec<DisplayItem>, run: &mut String, run_style: Option<(Option<u32>, bool, bool, Option<u16>)>, run_x: u32, y: u32) {
if run.is_empty() {
return;
}
if let Some((color, bold, heading, link)) = run_style {
items.push(DisplayItem::Text { x: run_x, y, text: core::mem::take(run), color, bold, heading, link });
} else {
run.clear();
}
}
/// Word-wrap `tokens` within `avail_w` pixels at `metrics`, starting at
/// `(x, y)`. Returns the total height consumed and the painted text runs
/// (consecutive same-style words on one line are coalesced into one
/// `DisplayItem::Text`).
fn layout_inline_tokens(tokens: &[InlineToken], x: u32, y: u32, avail_w: u32, metrics: Metrics) -> (u32, Vec<DisplayItem>) {
let mut items = Vec::new();
let char_w = metrics.char_w.max(1);
let max_chars = (avail_w / char_w).max(1);
let mut cx: u32 = 0;
let mut cy = y;
let mut any = false;
let mut run = String::new();
let mut run_style: Option<(Option<u32>, bool, bool, Option<u16>)> = None;
let mut run_x: u32 = 0;
let n = tokens.len();
let mut i = 0usize;
while i < n {
match &tokens[i] {
InlineToken::Break => {
flush_run(&mut items, &mut run, run_style.take(), x + run_x * char_w, cy);
cy += metrics.line_h;
cx = 0;
any = true;
i += 1;
},
InlineToken::Glue => {
i += 1;
},
InlineToken::Word { text, color, bold, heading, link } => {
let is_glued = i > 0 && matches!(tokens[i - 1], InlineToken::Glue);
let word_len = text.chars().count() as u32;
let need = if is_glued || cx == 0 { word_len } else { word_len + 1 };
if cx > 0 && cx + need > max_chars {
flush_run(&mut items, &mut run, run_style.take(), x + run_x * char_w, cy);
cy += metrics.line_h;
cx = 0;
}
let sep_needed = !(cx == 0 || is_glued);
let style_key = (*color, *bold, *heading, *link);
if run_style != Some(style_key) {
flush_run(&mut items, &mut run, run_style.take(), x + run_x * char_w, cy);
run_x = cx;
run_style = Some(style_key);
}
if sep_needed {
run.push(' ');
}
run.push_str(text);
cx += if sep_needed { word_len + 1 } else { word_len };
any = true;
i += 1;
},
}
}
flush_run(&mut items, &mut run, run_style.take(), x + run_x * char_w, cy);
let height = if any { (cy - y) + metrics.line_h } else { 0 };
(height, items)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{css, dom};
fn run(html: &[u8], w: u32) -> LayoutResult {
let d = dom::parse(html);
let s = css::cascade(&d);
layout_document(&d, &s, w, Metrics { char_w: 8, line_h: 14 })
}
#[test]
fn wraps_text_within_width() {
let r = run(b"<body><p>one two three four five six seven eight</p></body>", 8 * 10);
let text_items: Vec<_> = r.items.iter().filter(|i| matches!(i, DisplayItem::Text { .. })).collect();
assert!(text_items.len() > 1, "expected wrapping to produce multiple text runs, got {}", text_items.len());
}
#[test]
fn glued_inline_elements_touch_without_space() {
let r = run(b"<body><p>foo<b>bar</b></p></body>", 800);
let texts: Vec<String> = r
.items
.iter()
.filter_map(|i| if let DisplayItem::Text { text, .. } = i { Some(text.clone()) } else { None })
.collect();
let joined = texts.concat();
assert_eq!(joined, "foobar");
}
#[test]
fn separated_inline_elements_keep_one_space() {
let r = run(b"<body><p>foo <b>bar</b> baz</p></body>", 800);
let texts: Vec<String> = r
.items
.iter()
.filter_map(|i| if let DisplayItem::Text { text, .. } = i { Some(text.clone()) } else { None })
.collect();
let joined = texts.join("");
assert_eq!(joined, "foo bar baz");
}
#[test]
fn block_children_stack_vertically() {
let r = run(b"<body><div>a</div><div>b</div></body>", 800);
let ys: Vec<u32> = r
.items
.iter()
.filter_map(|i| if let DisplayItem::Text { y, .. } = i { Some(*y) } else { None })
.collect();
assert_eq!(ys.len(), 2);
assert!(ys[1] > ys[0], "second div should be laid out below the first: {:?}", ys);
}
#[test]
fn canvas_allocates_a_real_surface_of_requested_size() {
let r = run(b"<body><canvas id=\"c\" width=\"64\" height=\"32\"></canvas></body>", 800);
assert_eq!(r.canvases.len(), 1);
assert_eq!(r.canvases[0].w, 64);
assert_eq!(r.canvases[0].h, 32);
assert_eq!(r.canvas_ids[0], "c");
}
#[test]
fn explicit_width_and_background_produce_a_sized_rect() {
let r = run(b"<body><div style=\"width:100px;height:40px;background:#ff0000\"></div></body>", 800);
let rect = r.items.iter().find(|i| matches!(i, DisplayItem::Rect { .. }));
match rect {
Some(DisplayItem::Rect { w, h, color, .. }) => {
assert_eq!(*w, 100);
assert_eq!(*h, 40);
assert_eq!(*color, 0xFF0000);
},
_ => panic!("expected a background rect"),
}
}
#[test]
fn link_gets_an_index_and_href_is_recorded() {
let r = run(b"<body><a href=\"http://example.com\">go</a></body>", 800);
assert_eq!(r.links.len(), 1);
assert_eq!(r.links[0].href, "http://example.com");
let has_link = r.items.iter().any(|i| matches!(i, DisplayItem::Text { link: Some(0), .. }));
assert!(has_link, "expected the text run to carry link index 0");
}
#[test]
fn empty_block_contributes_no_height() {
let r = run(b"<body></body>", 800);
assert_eq!(r.height, 0);
assert!(r.items.is_empty());
}
}