灵符lingfu
H

horizon-browser

↓ 0 downloads

Horizon: 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-browser
LicenseMIT
AuthorsLing Lin <taellinglin@gmail.com>
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / 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());
    }
}

← Back to index