Merge pull request #3613 from lightpanda-io/scroll-text-extent

Measure wrapped text in an element's content height
This commit is contained in:
Adrià Arrufat authored and GitHub committed 2026-09-25 11:20:28 +02:00
commit efc239bbe8
3 files changed
+113 -6

No files matched your search

+30
View File
@@ -664,3 +664,33 @@
testing.expectEqual(window.innerHeight / 10, $('#sheetVh').clientHeight);
}
</script>
<div id="textBox" style="height: 100px; width: 200px; overflow: auto;">
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
</div>
<div id="textFree">one two three four five six seven eight nine ten eleven twelve</div>
<div id="textSmall" style="width: 200px; font-size: 8px">one two three four five six seven eight nine ten eleven twelve</div>
<script id="textWrapsAtExplicitWidth">
{
// Text wraps at an explicit width
const box = $('#textBox');
testing.expectEqual(100, box.clientHeight);
testing.expectEqual(200, box.scrollWidth);
testing.expectTrue(box.scrollHeight > box.clientHeight);
box.scrollTop = 9999;
testing.expectTrue(box.scrollTop > 0);
testing.expectEqual(box.scrollHeight - box.clientHeight, box.scrollTop);
// Without one, it isn't measured
testing.expectEqual(5, $('#textFree').scrollHeight);
const small = $('#textSmall');
const before = small.clientHeight;
small.style.fontSize = '4px';
testing.expectTrue(small.clientHeight < before);
}
</script>
+64
View File
@@ -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);
}
+19 -6
View File
@@ -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;
}