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; }