From 822eb1c2f664efd5e3741515b688ed3c54255b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 23 Sep 2026 13:02:20 +0200 Subject: [PATCH 1/2] Measure wrapped text in an element's content height A box whose content is only text had no content extent, so its scroll offset never clamped. With an explicit width, direct text children now wrap at it and add their lines to the content height. --- src/browser/tests/element/position.html | 31 +++++++++++ src/browser/text_measure.zig | 68 +++++++++++++++++++++++++ src/browser/webapi/Element.zig | 28 +++++++--- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index bf5e5d8ea..45877d739 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -664,3 +664,34 @@ testing.expectEqual(window.innerHeight / 10, $('#sheetVh').clientHeight); } + +
+ one two three four five six seven eight nine ten eleven twelve thirteen + fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty-one + twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven +
+
one two three four five six seven eight nine ten eleven twelve
+
one two three four five six seven eight nine ten eleven twelve
+ + diff --git a/src/browser/text_measure.zig b/src/browser/text_measure.zig index 461331cbf..5baae630d 100644 --- a/src/browser/text_measure.zig +++ b/src/browser/text_measure.zig @@ -63,6 +63,52 @@ pub fn substringWidth(text: []const u8, charnum: u32, nchars: u32, font_size: f6 return result; } +/// Line height for `line-height: normal`, in em. +pub const LINE_HEIGHT = 1.2; + +/// Greedy line breaking under `white-space: normal`: whitespace runs collapse +/// to one space, lines break between words, and a word wider than the line +/// overflows it rather than splitting. Text can arrive in pieces, one per text +/// node, and flows on from where the previous piece stopped. +pub const LineWrap = struct { + line_width: f64, + font_size: f64, + lines: u32 = 0, + // Width already taken on the current line. + x: f64 = 0, + // A collapsed space is due before the next word. + space: bool = false, + + pub fn add(self: *LineWrap, text: []const u8) void { + if (text.len == 0) { + return; + } + if (std.ascii.isWhitespace(text[0])) { + self.space = true; + } + var words = std.mem.tokenizeAny(u8, text, &std.ascii.whitespace); + while (words.next()) |word| { + const w = width(word, self.font_size); + const gap = if (self.space) advance(' ', self.font_size) else 0; + if (self.lines == 0) { + self.lines = 1; + self.x = w; + } else if (self.x + gap + w > self.line_width) { + self.lines += 1; + self.x = w; + } else { + self.x += gap + w; + } + self.space = true; + } + self.space = std.ascii.isWhitespace(text[text.len - 1]); + } + + pub fn height(self: LineWrap) f64 { + return @as(f64, @floatFromInt(self.lines)) * self.font_size * LINE_HEIGHT; + } +}; + fn advance(codepoint: u21, font_size: f64) f64 { if (isZeroWidth(codepoint)) return 0; if (codepoint == '\n' or codepoint == '\r') return 0; @@ -109,3 +155,25 @@ test "fallback metrics count utf-16 units and ignore combining marks" { try std.testing.expectError(error.IndexSizeError, substringWidth("A", 1, 0, 10)); try std.testing.expectError(error.IndexSizeError, substringWidth("", 0, 0, 10)); } + +test "LineWrap: collapses whitespace and breaks between words" { + // At 10px a letter is 6px and a space 3.3px: "aaaa" is 24px wide + var wrap: LineWrap = .{ .line_width = 60, .font_size = 10 }; + try std.testing.expectEqual(0, wrap.height()); + + // Two words and a space fit in 60px, the third wraps + wrap.add(" aaaa \n\t aaaa aaaa "); + try std.testing.expectEqual(2, wrap.lines); + + // The next piece flows on after the trailing space + wrap.add("aaaa"); + try std.testing.expectEqual(2, wrap.lines); + wrap.add("aaaa"); + try std.testing.expectEqual(3, wrap.lines); + try std.testing.expectApproxEqAbs(36, wrap.height(), 0.0001); + + // A word wider than the line takes a line of its own, unsplit + var narrow: LineWrap = .{ .line_width = 10, .font_size = 10 }; + narrow.add("aaaa aaaa"); + try std.testing.expectEqual(2, narrow.lines); +} diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index ff9d2e801..5c5331ca1 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -23,6 +23,7 @@ const js = @import("../js/js.zig"); const dump = @import("../dump.zig"); const Frame = @import("../Frame.zig"); const Factory = @import("../Factory.zig"); +const text_measure = @import("../text_measure.zig"); const CSS = @import("CSS.zig"); const Node = @import("Node.zig"); @@ -1745,26 +1746,41 @@ fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 { // `scrollWidth` passes a threshold (the infinite-marquee idiom) never // terminates when the metric ignores what it just inserted. // -// Text children are not measured. Estimating a text run from its length would -// need a per-character advance, which in turn has to track font-size or -// "shrink the font until it fits" loops stop converging — and it would report -// overflow for practically every element containing text, since a few words -// already exceed the default box. Element children are what content grown by -// script actually consists of. +// Text children count only toward the height, and only under an explicit +// width: that is a line length to wrap at, and wrapped text never overflows +// sideways. Without one, a few words would already exceed the default box and +// report overflow for practically every element containing text. The advance +// tracks the font size, so "shrink the font until it fits" loops converge. fn contentAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 { var total: f64 = 0; const owner = self.ownerFrame(frame) orelse return 0; const style_manager = &owner._style_manager; + var wrap: ?text_measure.LineWrap = null; + if (axis == .height) { + const width = self.getElementAxis(frame, .width); + if (width.explicit) { + wrap = .{ .line_width = width.value, .font_size = style_manager.computedFontSize(self) }; + } + } + var child = self.asNode().firstChild(); while (child) |node| : (child = node.nextSibling()) { if (node.is(Element)) |el| { if (!style_manager.hasDisplayNone(el)) { total += el.getElementAxis(frame, axis).value; } + } else if (wrap) |*w| { + if (node.is(Node.CData.Text)) |text| { + w.add(text.ownData()); + } } } + if (wrap) |w| { + // Whole pixels, like the scroll offsets clamped against it + total += @ceil(w.height()); + } return total; } From 20b0943a21a7020041d1d905fc983de863b2968a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 23 Sep 2026 13:46:15 +0200 Subject: [PATCH 2/2] Trim comments --- src/browser/tests/element/position.html | 5 ++--- src/browser/text_measure.zig | 16 ++++++---------- src/browser/webapi/Element.zig | 9 +++------ 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index 45877d739..17568d68c 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -675,7 +675,7 @@