diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html
index bf5e5d8ea..17568d68c 100644
--- a/src/browser/tests/element/position.html
+++ b/src/browser/tests/element/position.html
@@ -664,3 +664,33 @@
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..31edde05f 100644
--- a/src/browser/text_measure.zig
+++ b/src/browser/text_measure.zig
@@ -63,6 +63,50 @@ 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`. Text can be added in
+/// pieces (one per text node) that flow on from each other.
+pub const LineWrap = struct {
+ line_width: f64,
+ font_size: f64,
+ lines: u32 = 0,
+ // Width used on the current line.
+ x: f64 = 0,
+ // A 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 +153,23 @@ 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, a space 3.3px
+ var wrap: LineWrap = .{ .line_width = 60, .font_size = 10 };
+ try std.testing.expectEqual(0, wrap.height());
+
+ wrap.add(" aaaa \n\t aaaa aaaa ");
+ try std.testing.expectEqual(2, wrap.lines);
+
+ 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);
+
+ // Long words aren't split
+ 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 741ba73a8..3d82f1801 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");
@@ -1756,26 +1757,38 @@ 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 add height only under an explicit width to wrap at.
+// Otherwise almost every element with text would report overflow.
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 scroll offsets
+ total += @ceil(w.height());
+ }
return total;
}