Dev Study
← Rust で小さなブラウザを作る コース

22. 位置と大きさ — block は縦に積み、inline は横に並べ、右端で折り返す

(normal flow)の規則は 2 つだけです。 のは縦に積まれ、幅は親いっぱい、高さは中身で決まる。 の箱と文字は横に並び、右端に達したら次の行へ折り返す。CSS 2 §9 の visual formatting model のうち、float や position を使わないときの振る舞いがこれです。margin / padding / border は省いているので、箱の位置は直前の箱の終わりから始まります。

参照実装は、幅を上から下へ、高さを下から上へ決めます。layout_block は自分の x, y を親から受け取り、幅を style.width か親の幅(auto)にしてから子を置き、全部置き終わった y から自分の高さ(auto)を求めます。だから親の高さは子の配置が終わるまで分かりません。文字の大きさは等幅フォントの近似で、1 文字 = font-size × 0.5 、1 行 = font-size × 1.5 px です。の幅は 640px(80 文字)にしてあります。

行の管理は Cursor(x, y, 行の左端 left、右端 right、今の行の高さ line_height)です。文字の箱は、右端までに入る文字数だけを 1 行に置き、残りがあれば newline(y を line_height 分進めて x を left に戻す)して続きを置きます。単語の境界は見ず文字単位でます(本物は単語境界と禁則を見ます)。h1 の 32px の文字が混じる行は line_height がその分高くなり、行の高さが一番大きい文字で決まる、という本物と同じ性質が出ます。

block の子が来たら、まず今の行を確定(newline)してから、その block を cur.left, cur.y に置き、終わったら y を block の高さだけ進めます。inline の子は Cursor をそのまま子に渡して中の文字を続けて置かせます。前のステップの実行結果で、<p>ab<a>cd</a>ef</p> の <a> が x=16(2 文字 × 8px)から始まり、100 文字の段落が 80 文字で折り返して高さが 48(2 行)になるのを確かめてください。

本物のブラウザでは: の は、入力を BlockNode(と)と (親から来る制約)に限り、「現在のレイアウトはこの集合の外の情報に触ってはならない」と決めています。出力は書き換え不可の の木で、同じ入力なら前回の結果を再利用します(CachedLayoutResult)。アルゴリズムは display ごとに分かれ、行の折り返しは LineBreaker です。この回の Cursor を渡す layout_block() は ConstraintSpace の最小版、 を直接書き換える点は LayoutNG 以前の形です。Firefox は と呼び、ReflowInput(親の制約)と ReflowOutput(結果)を使い、NS_FRAME_IS_DIRTY のビットで必要な部分だけやり直します。文字幅は本物は で実測し(Blink の harfbuzz_shaper.cc、Firefox の gfx/harfbuzz)、この実装の文字数 × 比率は近似です。

開発のどこに活かすか: 「幅は親から、高さは子から」は、CSS の height: 100% が思いどおりにならない理由(親の高さがまだ決まっていない)や、レイアウトの計算コスト(子を全部置くまで親が確定しない=木全体を 1 回なめる)の理解に直結します。 を 1 要素ずつ触って毎回レイアウトを起こすと遅い、まとめて書き換えて 1 回で済ませる、という実務の指針は、この再計算の重さから来ています。

点線の付いた語はタップすると説明が出ます。用語集を開く →

① Cursor が行を進める

<p>ab<a>cd</a>ef</p> を幅 640px、16px の文字(8px 幅、24px 行)で置く
Cursor 初期x=0 y=0 left=0 right=640 line_height=0
text "ab": 2 文字入る
行に "ab" を x=0x → 16, line_height → 24
inline <a>: 子に Cursor を渡す
<a> は x=16 y=0 から中の "cd" を x=16 に、x → 32
text "ef"
"ef" を x=32x → 48
子が尽きたので newline
p の高さ = 24y が 0 → 24 に進んだ分
  • ・100 文字の文字列なら 80 文字で newline が入り、2 行目は y=24。段落の高さは 48
  • ・次の block はこの高さの分だけ下から始まる

Cursor を子に渡して返してもらう、という 1 本の流れで、inline の入れ子も折り返しも扱えます。

サンプルコード(フレームワーク環境が必要なため表示のみ)

// src/renderer/layout.rs の位置と大きさを決める部分
fn layout_block(b: &mut LayoutBox, x: f32, y: f32, container_width: f32) {
    b.x = x;
    b.y = y;
    // 幅: 指定があればそれ、無ければ親いっぱい(auto)
    b.width = b.style.width.unwrap_or(container_width);
    let width = b.width;
    let mut cur = Cursor {
        x,
        y,
        left: x,
        right: x + width,
        line_height: 0.0,
    };
    layout_flow(&mut b.children, &mut cur, width);
    if cur.line_height > 0.0 {
        cur.newline(); // 最後の行を確定する
    }
    // 高さ: 指定があればそれ、無ければ中身の分(auto)
    b.height = b.style.height.unwrap_or(cur.y - y);
}

fn layout_flow(children: &mut [LayoutBox], cur: &mut Cursor, container_width: f32) {
    for child in children.iter_mut() {
        match child.kind.clone() {
            BoxKind::Block => {
                if cur.line_height > 0.0 {
                    cur.newline();
                }
                layout_block(child, cur.left, cur.y, container_width);
                cur.y = child.y + child.height;
                cur.x = cur.left;
                cur.line_height = 0.0;
            }
            BoxKind::Inline => {
                child.x = cur.x;
                child.y = cur.y;
                let start_y = cur.y;
                layout_flow(&mut child.children, cur, container_width);
                // inline の箱の大きさは「子が占めた範囲」の近似
                child.width = if cur.y == start_y { cur.x - child.x } else { container_width };
                child.height = (cur.y + cur.line_height) - start_y;
            }
            BoxKind::Text(text) => {
                let font = child.style.font_size;
                let char_w = font * CHAR_WIDTH_RATIO;
                let line_h = font * LINE_HEIGHT_RATIO;
                child.x = cur.x;
                child.y = cur.y;
                let chars: Vec<char> = text.chars().collect();
                let mut rest: &[char] = &chars;
                let mut lines = Vec::new();
                while !rest.is_empty() {
                    let mut avail = ((cur.right - cur.x) / char_w).floor() as usize;
                    if avail == 0 {
                        if cur.x > cur.left {
                            cur.newline(); // 行末に来たので折り返す
                            continue;
                        }
                        avail = 1; // 行頭でも 1 文字入らないなら、はみ出してでも 1 文字置く
                    }
                    let take = avail.min(rest.len());
                    let (head, tail) = rest.split_at(take);
                    lines.push(TextLine {
                        text: head.iter().collect(),
                        x: cur.x,
                        y: cur.y,
                    });
                    cur.x += take as f32 * char_w;
                    cur.line_height = cur.line_height.max(line_h);
                    rest = tail;
                    if !rest.is_empty() {
                        cur.newline();
                    }
                }
                child.lines = lines;
                child.width = (text.chars().count() as f32 * char_w).min(container_width);
                child.height = (cur.y + cur.line_height) - child.y;
            }
        }
    }
}

手元で動かす(配布物)

サンプルコードで身につける(3本) →

学んだ概念を、素朴な例から実務寄りの例まで3つの具体例で確認します。

公式ドキュメントで詳しく ↗