Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 32 additions & 5 deletions src/style/style.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
191 changes: 191 additions & 0 deletions tests/style_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading