From e823dc05288a419e6b2610ade3e833e1c5142bc8 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 3 Sep 2026 00:40:43 -0700 Subject: [PATCH] fix: honour Style height, maxHeight and valign (#147) writeStyledContent computed inner_height and then discarded it, so a style's height was never reflected in the rendered block: the content lines were emitted as-is. maxHeight and valign were dead for the same reason. The symptom is most visible when a style is sized from a flex `.fill` rect - the block collapses to the height of its text instead of filling the row it was given. Fit the content block to inner_height instead: pad short content with blank rows placed according to align_vertical, and drop the tail of content that exceeds the box. height only ever grows the box (taller content is not silently cut off); maxHeight is the clamp that truncates. Inline output is a single row, so the fill is skipped there. The content box is also now at least one row tall, matching the fact that a render always emits a line even for empty text. --- REFERENCE.md | 15 ++++ src/style/style.zig | 37 ++++++-- tests/style_tests.zig | 191 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 5 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index 287c2c3..8c34a1f 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -161,6 +161,21 @@ const style = (zz.Style{}) const output = try style.render(allocator, "Hello, World!"); // render() does not append an implicit trailing '\n' +// Sizing +// +// `width`/`height` size the content box, so padding and borders are added on +// top of them. `height` is a minimum: content taller than it grows the block +// rather than being cut off. Short content is padded with blank rows, placed +// according to `valign`. +const panel = (zz.Style{}) + .borderAll(.rounded) + .width(rect.width - 2) // minus the left and right border columns + .height(rect.height - 2) // minus the top and bottom border rows + .valign(.middle); // .top (default), .middle, .bottom + +// `maxHeight` is the hard clamp - it truncates rows that do not fit. +const clamped = (zz.Style{}).maxHeight(3); + // Text transforms const upper_style = (zz.Style{}).transform(.uppercase); const shouting = try upper_style.render(allocator, "hello"); // "HELLO" diff --git a/src/style/style.zig b/src/style/style.zig index 22b16e2..7978bc5 100644 --- a/src/style/style.zig +++ b/src/style/style.zig @@ -611,12 +611,15 @@ pub const Style = struct { const content_width = measure.width(processed_text); const content_height = measure.height(processed_text); - // Apply size constraints + // Apply size constraints. A render always emits at least one line, even + // for empty text, so the content box is never shorter than one row. var target_width = content_width; - var target_height = content_height; + var target_height = @max(content_height, 1); if (self.width_val) |w| target_width = w; - if (self.height_val) |h| target_height = h; + // height is a minimum: content taller than it grows the box instead of + // being silently cut off. max_height is the hard clamp that truncates. + if (self.height_val) |h| target_height = @max(target_height, h); if (self.max_width_val) |mw| target_width = @min(target_width, mw); if (self.max_height_val) |mh| target_height = @min(target_height, mh); @@ -681,7 +684,19 @@ pub const Style = struct { @as(usize, if (self.border_sides.top) 1 else 0) -| @as(usize, if (self.border_sides.bottom) 1 else 0); - _ = inner_height; + // Fit the content block to inner_height. Short content is padded with + // blank lines distributed according to align_vertical; content longer + // than the box (only reachable via max_height) has its tail dropped. + // Inline output is a single row, so there is nothing to stretch into. + const line_count = std.mem.count(u8, text, "\n") + 1; + const fill = if (self.inline_mode) 0 else inner_height -| line_count; + const fill_top: usize = switch (self.align_vertical) { + .top => 0, + .middle => fill / 2, + .bottom => fill, + }; + const fill_bottom = fill - fill_top; + const visible_lines = @min(line_count, inner_height); // Start style try self.writeStyleStart(writer); @@ -705,12 +720,24 @@ pub const Style = struct { try self.writeContentLine(writer, "", inner_width); } + // Vertical fill above the content + for (0..fill_top) |_| { + try self.writeContentLine(writer, "", inner_width); + } + // Content lines var lines = std.mem.splitScalar(u8, text, '\n'); - while (lines.next()) |line| { + var emitted: usize = 0; + while (emitted < visible_lines) : (emitted += 1) { + const line = lines.next() orelse break; try self.writeContentLine(writer, line, inner_width); } + // Vertical fill below the content + for (0..fill_bottom) |_| { + try self.writeContentLine(writer, "", inner_width); + } + // Padding bottom for (0..self.padding_val.bottom) |_| { try self.writeContentLine(writer, "", inner_width); diff --git a/tests/style_tests.zig b/tests/style_tests.zig index a599f4d..d3624c0 100644 --- a/tests/style_tests.zig +++ b/tests/style_tests.zig @@ -213,3 +213,194 @@ test "compressAnsi keeps CSI sequences ending in a non-letter intact" { try testing.expectEqualStrings("\x1b[3~\x1b[0mtail", result); } + +// --------------------------------------------------------------------------- +// Vertical sizing: height, maxHeight and vertical alignment +// --------------------------------------------------------------------------- + +test "Style height pads the block to the requested number of rows" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.width(6); + style = style.height(4); + + const result = try style.render(allocator, "test"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings("test \n \n \n ", plain); +} + +test "Style height fills a flex row assigned by a .fill constraint" { + const allocator = testing.allocator; + + const rows = try zz.flex.layout(allocator, 20, 6, &.{ + .{ .constraint = .fill }, + }, .{ .direction = .column }); + defer allocator.free(rows); + + var style = zz.Style{}; + style = style.width(rows[0].width); + style = style.height(rows[0].height); + + const result = try style.render(allocator, "test"); + defer allocator.free(result); + + try testing.expectEqual(@as(usize, 6), zz.height(result)); + try testing.expectEqual(@as(usize, 20), zz.width(result)); +} + +test "Style height distributes the fill according to valign" { + const allocator = testing.allocator; + + var top = zz.Style{}; + top = top.width(1).height(3); + const top_result = try top.render(allocator, "x"); + defer allocator.free(top_result); + const top_plain = try zz.testing.stripAnsi(allocator, top_result); + defer allocator.free(top_plain); + try testing.expectEqualStrings("x\n \n ", top_plain); + + var middle = zz.Style{}; + middle = middle.width(1).height(3).valign(.middle); + const middle_result = try middle.render(allocator, "x"); + defer allocator.free(middle_result); + const middle_plain = try zz.testing.stripAnsi(allocator, middle_result); + defer allocator.free(middle_plain); + try testing.expectEqualStrings(" \nx\n ", middle_plain); + + var bottom = zz.Style{}; + bottom = bottom.width(1).height(3).valign(.bottom); + const bottom_result = try bottom.render(allocator, "x"); + defer allocator.free(bottom_result); + const bottom_plain = try zz.testing.stripAnsi(allocator, bottom_result); + defer allocator.free(bottom_plain); + try testing.expectEqualStrings(" \n \nx", bottom_plain); +} + +test "Style height fills inside padding and borders" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.width(2).height(3).paddingAll(1).borderAll(.normal); + + const result = try style.render(allocator, "hi"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + // 3 content rows + 2 padding rows + 2 border rows. + try testing.expectEqualStrings( + \\┌────┐ + \\│ │ + \\│ hi │ + \\│ │ + \\│ │ + \\│ │ + \\└────┘ + , plain); +} + +test "Style height is a minimum and never truncates content" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.height(2); + + const result = try style.render(allocator, "1\n2\n3\n4"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings("1\n2\n3\n4", plain); +} + +test "Style maxHeight truncates the content block" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.maxHeight(2); + + const result = try style.render(allocator, "1\n2\n3\n4"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings("1\n2", plain); +} + +test "Style maxHeight leaves content shorter than the cap alone" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.height(3).maxHeight(10); + + const result = try style.render(allocator, "1\n2\n3\n4\n5"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings("1\n2\n3\n4\n5", plain); +} + +test "Style maxHeight caps the padding added by height" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.width(1).height(8).maxHeight(3); + + const result = try style.render(allocator, "x"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings("x\n \n ", plain); +} + +test "Style height applies after overflow wrapping" { + // Arena: render() leaks the intermediate buffer applyOverflow hands back, + // which is a separate bug from the vertical sizing under test here. + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var style = zz.Style{}; + style = style.width(6).overflow(.word_wrap).height(5); + + const result = try style.render(allocator, "the quick brown fox"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings("the \nquick \nbrown \nfox \n ", plain); +} + +test "Style height stretches empty text" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.width(2).height(3); + + const result = try style.render(allocator, ""); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings(" \n \n ", plain); +} + +test "Style height is ignored in inline mode" { + const allocator = testing.allocator; + + var style = zz.Style{}; + style = style.width(2).height(4).inline_style(true); + + const result = try style.render(allocator, "x"); + defer allocator.free(result); + const plain = try zz.testing.stripAnsi(allocator, result); + defer allocator.free(plain); + + try testing.expectEqualStrings("x ", plain); +}