Files
zterm/src/container.zig
Yves Biener 6b2797cd8c
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m1s
mod(container): .fixed sizing behavior with minSize Container / Element function
2025-12-17 22:04:06 +01:00

1050 lines
41 KiB
Zig

// FIX known issues:
// - hold fewer instances of the `Allocator`
/// Border configuration struct
pub const Border = packed struct {
/// Color to use for the border
color: Color = .default,
/// Configure the corner type to be used for the border
corners: enum(u1) {
squared,
rounded,
} = .squared,
/// Configure the sides where the borders shall be rendered
sides: packed struct {
top: bool = false,
bottom: bool = false,
left: bool = false,
right: bool = false,
/// Enable border sides for all four sides
pub const all: @This() = .{ .top = true, .bottom = true, .left = true, .right = true };
/// Enable border sides for the left and right sides
pub const horizontal: @This() = .{ .left = true, .right = true };
/// Enable border sides for the top and bottom sides
pub const vertical: @This() = .{ .top = true, .bottom = true };
} = .{},
pub fn content(this: @This(), cells: []Cell, size: Point) void {
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const frame: [6]u21 = switch (this.corners) {
.rounded => .{ '╭', '─', '╮', '│', '╰', '╯' },
.squared => .{ '┌', '─', '┐', '│', '└', '┘' },
};
// render top and bottom border
if (this.sides.top or this.sides.bottom) {
for (0..size.x) |col| {
const last_row = @as(usize, size.y - 1) * @as(usize, size.x);
if (this.sides.left and col == 0) {
// top left corner
if (this.sides.top) cells[col].cp = frame[0];
// bottom left corner
if (this.sides.bottom) cells[last_row + col].cp = frame[4];
} else if (this.sides.right and col == size.x - 1) {
// top right corner
if (this.sides.top) cells[col].cp = frame[2];
// bottom left corner
if (this.sides.bottom) cells[last_row + col].cp = frame[5];
} else {
// top side
if (this.sides.top) cells[col].cp = frame[1];
// bottom side
if (this.sides.bottom) cells[last_row + col].cp = frame[1];
}
if (this.sides.top) cells[col].style.fg = this.color;
if (this.sides.bottom) cells[last_row + col].style.fg = this.color;
}
}
// render left and right border
if (this.sides.left or this.sides.right) {
var start: usize = 0;
if (this.sides.top) start = 1;
var end = size.y;
if (this.sides.bottom) end -= 1;
for (start..end) |row| {
const idx = (row * size.x);
if (this.sides.left) {
cells[idx].cp = frame[3]; // left
cells[idx].style.fg = this.color;
}
if (this.sides.right) {
cells[idx + size.x - 1].cp = frame[3]; // right
cells[idx + size.x - 1].style.fg = this.color;
}
}
}
}
test "all sides" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .green,
.sides = .all,
},
}, .{});
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/border.all.zon"));
}
test "vertical sides" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .green,
.sides = .vertical,
},
}, .{});
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/border.vertical.zon"));
}
test "horizontal sides" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .green,
.sides = .horizontal,
},
}, .{});
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/border.horizontal.zon"));
}
};
/// Rectangle configuration struct
pub const Rectangle = packed struct {
/// `Color` to use to fill the `Rectangle` with
/// NOTE used as background color when rendering! such that it renders the
/// children accordingly without removing the coloring of the `Rectangle`
fill: Color = .default,
// NOTE caller owns `Cells` slice and ensures that `cells.len == size.x * size.y`
pub fn content(this: @This(), cells: []Cell, size: Point) void {
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
for (0..size.y) |row| {
for (0..size.x) |col| {
cells[(row * size.x) + col].style.bg = this.fill;
}
}
// DEBUG render corresponding beginning of the rectangle for this `Container` *red*
if (comptime build_options.debug) {
cells[0].style.fg = .red;
cells[0].style.bg = .black;
cells[0].cp = 'r'; // 'r' for *rectangle*
}
}
test "fill color overwrite parent fill" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .green },
}, .{});
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/rectangle_with_parent_fill_without_padding.zon"));
}
test "fill color padding to show parent fill" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.padding = .all(2),
},
.rectangle = .{ .fill = .green },
}, .{});
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/rectangle_with_parent_padding.zon"));
}
test "fill color padding to show parent fill (negative padding)" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.padding = .{
.top = -18,
.bottom = -18,
.left = -28,
.right = -28,
},
},
.rectangle = .{ .fill = .green },
}, .{});
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/rectangle_with_parent_padding.zon"));
}
test "fill color spacer with padding" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{
.fill = .black,
},
.layout = .{
.padding = .vertical(2),
.direction = .vertical,
},
}, .{});
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/rectangle_with_padding.zon"));
}
test "fill color with gap" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{
.fill = .black,
},
.layout = .{
.padding = .vertical(1),
.gap = 1,
.direction = .vertical,
},
}, .{});
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .blue },
}, .{}));
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/rectangle_with_gap.zon"));
}
test "fill color with separator" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{
.fill = .black,
},
.layout = .{
.padding = .vertical(1),
.separator = .{
.enabled = true,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .blue },
}, .{}));
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .white },
}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/rectangle_with_separator.zon"));
}
};
/// Layout configuration struct
pub const Layout = packed struct {
/// control the direction in which child elements are laid out
direction: enum(u1) { horizontal, vertical } = .horizontal,
/// Padding outside of the child elements
padding: packed struct {
top: i16 = 0,
bottom: i16 = 0,
left: i16 = 0,
right: i16 = 0,
/// Create a padding with equivalent padding in all four directions.
pub fn all(padding: i16) @This() {
return .{ .top = padding, .bottom = padding, .left = padding, .right = padding };
}
/// Create a padding with equivalent padding in the left and right directions; others directions remain the default value.
pub fn horizontal(padding: i16) @This() {
return .{ .left = padding, .right = padding };
}
/// Create a padding with equivalent padding in the top and bottom directions; others directions remain the default value.
pub fn vertical(padding: i16) @This() {
return .{ .top = padding, .bottom = padding };
}
} = .{},
/// Padding used in between child elements as gaps when laid out
gap: u16 = 0,
/// Configure separator borders between child element to added to the layout
separator: packed struct {
enabled: bool = false,
color: Color = .white,
line: enum(u2) {
line,
dotted,
double,
} = .line,
} = .{},
/// Calculate the absolute offset for the provided `padding` if it is negative to get the absolute padding for the given `size`.
pub fn getAbsolutePadding(padding: i16, size: u16) u16 {
return if (padding >= 0) @intCast(padding) else size -| @as(u16, @intCast(-padding));
}
pub fn content(this: @This(), comptime C: type, cells: []Cell, origin: Point, size: Point, children: []const C) void {
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
if (this.separator.enabled and children.len > 1) {
const line_cps: [2]u21 = switch (this.separator.line) {
.line => .{ '│', '─' },
.dotted => .{ '┆', '┄' },
.double => .{ '║', '═' },
};
const gap: u16 = (this.gap + 1) / 2;
for (0..children.len - 1) |idx| {
const child = children[idx];
const anchor = switch (this.direction) {
.horizontal => ((@as(usize, child.origin.y) - @as(usize, origin.y)) * @as(usize, size.x)) + @as(usize, child.origin.x) + @as(usize, child.size.x) + gap - @as(usize, origin.x),
.vertical => ((@as(usize, child.origin.y) + @as(usize, child.size.y) + gap - @as(usize, origin.y)) * @as(usize, size.x)) + @as(usize, child.origin.x) - @as(usize, origin.x),
};
switch (this.direction) {
.horizontal => for (0..child.size.y) |row| {
cells[anchor + row * size.x].cp = line_cps[0];
cells[anchor + row * size.x].style.fg = this.separator.color;
},
.vertical => for (0..child.size.x) |col| {
cells[anchor + col].cp = line_cps[1];
cells[anchor + col].style.fg = this.separator.color;
},
}
// DEBUG render corresponding beginning of the separator for this `Container` *red*
if (comptime build_options.debug) {
cells[anchor].style.fg = .red;
cells[anchor].style.bg = .black;
cells[anchor].cp = 's'; // 's' for *separator*
}
}
}
}
test "separator without gaps" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.separator = .{
.enabled = true,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/separator_no_gaps.zon"));
}
test "separator without gaps with padding" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.padding = .all(1),
.separator = .{
.enabled = true,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/separator_no_gaps_with_padding.zon"));
}
test "separator(2x) without gaps" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.direction = .vertical,
.separator = .{
.enabled = true,
.color = .red,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
try container.append(try .init(std.testing.allocator, .{}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/separator_2x_no_gaps.zon"));
}
test "separator(2x) with border(all)" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
},
.layout = .{
.separator = .{
.enabled = true,
.color = .red,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/separator_2x_no_gaps_with_border.zon"));
}
test "separator(2x) with border(all) and padding(all(1))" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
},
.layout = .{
.padding = .all(1),
.separator = .{
.enabled = true,
.color = .red,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/separator_2x_no_gaps_with_padding.zon"));
}
test "separator(2x) with border(all) and gap" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
},
.layout = .{
.gap = 2,
.separator = .{
.enabled = true,
.color = .red,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/separator_2x_with_gaps_with_border.zon"));
}
test "separator(2x) with border(all) and gap and padding" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
},
.layout = .{
.gap = 2,
.padding = .all(1),
.separator = .{
.enabled = true,
.color = .red,
},
},
}, .{});
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
try container.append(try .init(std.testing.allocator, .{ .rectangle = .{ .fill = .white } }, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/separator_2x_with_gaps_with_border_with_padding.zon"));
}
};
/// Sizing options which should be used by the `Container`
pub const Size = packed struct {
dim: Point = .{},
grow: enum(u2) {
both,
fixed,
vertical,
horizontal,
} = .both,
};
pub fn Container(Model: type, Event: type) type {
if (!isTaggedUnion(Event)) @compileError("Provided user event `Event` for `Container(comptime Event: type)`");
const Element = @import("element.zig").Element(Model, Event);
return struct {
allocator: Allocator,
origin: Point,
size: Point,
properties: Properties,
element: Element,
// TODO this should be renamed to `children`
elements: std.ArrayList(@This()),
/// Properties for each `Container` to configure their layout,
/// border, styling, etc. For details see the corresponding individual
/// documentation of the members of this struct accordingly.
pub const Properties = packed struct {
border: Border = .{},
rectangle: Rectangle = .{},
layout: Layout = .{},
size: Size = .{},
};
pub fn init(
allocator: Allocator,
properties: Properties,
element: Element,
) !@This() {
return .{
.allocator = allocator,
.origin = .{},
.size = .{},
.properties = properties,
.element = element,
.elements = try std.ArrayList(@This()).initCapacity(allocator, 2),
};
}
pub fn deinit(this: *@This()) void {
for (this.elements.items) |*element| element.deinit();
this.elements.deinit(this.allocator);
this.element.deinit();
}
pub fn append(this: *@This(), element: @This()) !void {
try this.elements.append(this.allocator, element);
}
pub fn reposition(this: *@This(), model: *const Model, origin: Point) void {
const layout = this.properties.layout;
this.origin = origin;
this.element.reposition(model, origin);
var offset = origin.add(.{
.x = Layout.getAbsolutePadding(layout.padding.left, this.size.x),
.y = Layout.getAbsolutePadding(layout.padding.top, this.size.y),
});
const sides = this.properties.border.sides;
if (sides.left) offset.x += 1;
if (sides.top) offset.y += 1;
for (this.elements.items) |*child| {
child.reposition(model, offset);
switch (layout.direction) {
.horizontal => offset.x += child.size.x + layout.gap,
.vertical => offset.y += child.size.y + layout.gap,
}
if (layout.separator.enabled) switch (layout.direction) {
.horizontal => offset.x += 1,
.vertical => offset.y += 1,
};
}
}
/// Resize all fit sized `Containers` to the necessary size required by its child elements.
fn fit_resize(this: *@This(), model: *const Model) Point {
// NOTE this is supposed to be a simple and easy to understand algorithm, there are currently no optimizations done
const layout = this.properties.layout;
var size: Point = switch (layout.direction) {
.horizontal => .{ .x = layout.gap * @as(u16, @truncate(this.elements.items.len -| 1)) },
.vertical => .{ .y = layout.gap * @as(u16, @truncate(this.elements.items.len -| 1)) },
};
if (this.elements.items.len > 0) switch (layout.direction) {
.horizontal => size.x += Layout.getAbsolutePadding(layout.padding.left, this.size.x) + Layout.getAbsolutePadding(layout.padding.right, this.size.x),
.vertical => size.y += Layout.getAbsolutePadding(layout.padding.top, this.size.y) + Layout.getAbsolutePadding(layout.padding.bottom, this.size.y),
};
const sides = this.properties.border.sides;
if (sides.left) size.x += 1;
if (sides.right) size.x += 1;
if (sides.top) size.y += 1;
if (sides.bottom) size.y += 1;
if (layout.separator.enabled) switch (layout.direction) {
.horizontal => size.x += @as(u16, @truncate(this.elements.items.len -| 1)),
.vertical => size.y += @as(u16, @truncate(this.elements.items.len -| 1)),
};
for (this.elements.items) |*child| {
const child_size = child.fit_resize(model);
switch (layout.direction) {
.horizontal => {
size.x += child_size.x;
size.y = @max(size.y, child_size.y);
},
.vertical => {
size.x = @max(size.x, child_size.x);
size.y += child_size.y;
},
}
}
size = this.minSize(model, size);
// assign currently calculated size
this.size = switch (this.properties.size.grow) {
.both => .max(size, this.properties.size.dim),
.fixed => if (this.properties.size.dim.x != 0 and this.properties.size.dim.y != 0) this.properties.size.dim else size,
.horizontal => .{
.x = @max(size.x, this.properties.size.dim.x),
.y = this.properties.size.dim.y,
},
.vertical => .{
.x = this.properties.size.dim.x,
.y = @max(size.y, this.properties.size.dim.y),
},
};
return this.size;
}
/// growable implicitly requires the root `Container` to have a set a size property to the size of the available terminal screen
fn grow_resize(this: *@This(), model: *const Model, max_size: Point) void {
const layout = this.properties.layout;
var remainder = switch (layout.direction) {
.horizontal => max_size.x -| (Layout.getAbsolutePadding(layout.padding.left, this.size.x) + Layout.getAbsolutePadding(layout.padding.right, this.size.x)),
.vertical => max_size.y -| (Layout.getAbsolutePadding(layout.padding.top, this.size.y) + Layout.getAbsolutePadding(layout.padding.bottom, this.size.y)),
};
remainder -|= layout.gap * @as(u16, @truncate(this.elements.items.len -| 1));
if (layout.separator.enabled) remainder -|= @as(u16, @truncate(this.elements.items.len -| 1));
var available = switch (layout.direction) {
.horizontal => max_size.y -| (Layout.getAbsolutePadding(layout.padding.top, this.size.y) + Layout.getAbsolutePadding(layout.padding.bottom, this.size.y)),
.vertical => max_size.x -| (Layout.getAbsolutePadding(layout.padding.left, this.size.x) + Layout.getAbsolutePadding(layout.padding.right, this.size.x)),
};
const sides = this.properties.border.sides;
switch (layout.direction) {
.horizontal => {
if (sides.top) {
available -|= 1;
remainder -|= 1;
}
if (sides.bottom) {
available -|= 1;
remainder -|= 1;
}
},
.vertical => {
if (sides.left) {
available -|= 1;
remainder -|= 1;
}
if (sides.right) {
available -|= 1;
remainder -|= 1;
}
},
}
for (this.elements.items) |child| remainder -|= switch (layout.direction) {
.horizontal => child.size.x,
.vertical => child.size.y,
};
var growable_children: usize = 0;
var first_growable_child: *@This() = undefined;
for (this.elements.items) |*child| {
// layout direction side growth
switch (child.properties.size.grow) {
.fixed => continue,
.both => {
if (growable_children == 0) first_growable_child = child;
growable_children += 1;
},
.horizontal => if (layout.direction == .horizontal) {
if (growable_children == 0) first_growable_child = child;
growable_children += 1;
},
.vertical => if (layout.direction == .vertical) {
if (growable_children == 0) first_growable_child = child;
growable_children += 1;
},
}
// non layout direction side growth
switch (layout.direction) {
.horizontal => if (child.properties.size.grow == .vertical or child.properties.size.grow == .both) {
child.size.y = available;
},
.vertical => if (child.properties.size.grow == .horizontal or child.properties.size.grow == .both) {
child.size.x = available;
},
}
}
while (growable_children > 0 and remainder > 0) {
var smallest_size = switch (layout.direction) {
.horizontal => first_growable_child.size.x,
.vertical => first_growable_child.size.y,
};
var second_smallest_size: u16 = std.math.maxInt(u16);
var size_to_correct = remainder;
for (this.elements.items) |child| {
if (child.properties.size.grow == .fixed) continue;
switch (layout.direction) {
.horizontal => if (child.properties.size.grow == .vertical) continue,
.vertical => if (child.properties.size.grow == .horizontal) continue,
}
const size = switch (layout.direction) {
.horizontal => child.size.x,
.vertical => child.size.y,
};
if (size < smallest_size) {
second_smallest_size = smallest_size;
smallest_size = size;
} else if (size > smallest_size) {
second_smallest_size = @min(size, second_smallest_size);
size_to_correct = second_smallest_size -| smallest_size;
}
}
size_to_correct = @min(size_to_correct, remainder / growable_children);
var overflow: u16 = 0;
if (size_to_correct == 0 and remainder > 0) overflow = remainder;
for (this.elements.items) |*child| {
const child_size = switch (layout.direction) {
.horizontal => child.size.x,
.vertical => child.size.y,
};
if (child.properties.size.grow != .fixed and child_size == smallest_size) {
switch (layout.direction) {
.horizontal => if (child.properties.size.grow != .vertical) {
child.size.x += size_to_correct;
remainder -|= size_to_correct;
},
.vertical => if (child.properties.size.grow != .horizontal) {
child.size.y += size_to_correct;
remainder -|= size_to_correct;
},
}
if (overflow > 0) {
switch (layout.direction) {
.horizontal => if (child.properties.size.grow != .vertical) {
child.size.x += 1;
overflow -|= 1;
remainder -|= 1;
},
.vertical => if (child.properties.size.grow != .horizontal) {
child.size.y += 1;
overflow -|= 1;
remainder -|= 1;
},
}
}
}
}
}
this.element.resize(model, this.size);
for (this.elements.items) |*child| child.grow_resize(model, child.size);
}
pub fn resize(this: *@This(), model: *const Model, size: Point) void {
// NOTE assume that this function is only called for the root `Container`
this.size = size;
const fit_size = this.fit_resize(model);
this.size = switch (this.properties.size.grow) {
.both => .max(size, fit_size),
.fixed => fit_size,
.horizontal => .{
.x = @max(size.x, fit_size.x),
.y = size.y,
},
.vertical => .{
.x = size.x,
.y = @max(size.y, fit_size.y),
},
};
this.grow_resize(model, this.size);
}
pub fn minSize(this: *const @This(), model: *const Model, size: Point) Point {
var min_size: Point = .{};
for (this.elements.items) |*child| {
const child_size = child.minSize(model, child.properties.size.dim);
min_size = switch (this.properties.layout.direction) {
.horizontal => .{
.x = child_size.x + min_size.x,
.y = @max(child_size.y, min_size.y),
},
.vertical => .{
.x = @max(child_size.x, min_size.x),
.y = child_size.y + min_size.y,
},
};
}
const element_size = this.element.minSize(model, size);
return .max(element_size, min_size);
}
pub fn handle(this: *const @This(), model: *Model, event: Event) !void {
switch (event) {
.mouse => |mouse| if (mouse.in(this.origin, this.size)) {
// the element receives the mouse event with relative position
assert(mouse.x >= this.origin.x and mouse.y >= this.origin.y);
var relative_mouse: input.Mouse = mouse;
relative_mouse.x -= this.origin.x;
relative_mouse.y -= this.origin.y;
try this.element.handle(model, .{ .mouse = relative_mouse });
},
.bell => {
// ring the terminal bell and do not propagate the event any further
_ = try terminal.ringBell();
return;
},
else => try this.element.handle(model, event),
}
for (this.elements.items) |*element| try element.handle(model, event);
}
pub fn content(this: *const @This(), model: *const Model) ![]Cell {
if (this.size.x == 0 or this.size.y == 0) return Error.TooSmall;
const cells = try this.allocator.alloc(Cell, @as(usize, this.size.x) * @as(usize, this.size.y));
errdefer this.allocator.free(cells);
@memset(cells, .{});
this.properties.layout.content(@This(), cells, this.origin, this.size, this.elements.items);
this.properties.border.content(cells, this.size);
this.properties.rectangle.content(cells, this.size);
try this.element.content(model, cells, this.size);
// DEBUG render corresponding corners (except top left) of this `Container` *red*
if (comptime build_options.debug) {
// top right
cells[this.size.x -| 1].style.fg = .red;
cells[this.size.x -| 1].style.bg = .black;
cells[this.size.x -| 1].cp = 'c'; // 'c' for *container*
// bottom left
cells[this.size.x * (this.size.y -| 1)].style.fg = .red;
cells[this.size.x * (this.size.y -| 1)].style.bg = .black;
cells[this.size.x * (this.size.y -| 1)].cp = 'c'; // 'c' for *container*
// bottom right
cells[this.size.x * this.size.y -| 1].style.fg = .red;
cells[this.size.x * this.size.y -| 1].style.bg = .black;
cells[this.size.x * this.size.y -| 1].cp = 'c'; // 'c' for *container*
}
return cells;
}
};
}
const log = std.log.scoped(.container);
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const build_options = @import("build_options");
const input = @import("input.zig");
const terminal = @import("terminal.zig");
const isTaggedUnion = @import("event.zig").isTaggedUnion;
const Cell = @import("cell.zig");
const Color = @import("color.zig").Color;
const Point = @import("point.zig").Point;
const Style = @import("style.zig");
const Error = @import("error.zig").Error;
test {
@import("std").testing.refAllDeclsRecursive(@This());
}
test "Container Fixed and Grow Size Vertical" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{ .direction = .vertical },
}, .{});
try container.append(try .init(std.testing.allocator, .{
.size = .{
.dim = .{ .y = 5 },
.grow = .horizontal,
},
.rectangle = .{ .fill = .grey },
}, .{}));
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .red },
}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/fixed_grow_vertical.zon"));
}
test "Container Fixed and Grow Size Horizontal" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{}, .{});
try container.append(try .init(std.testing.allocator, .{
.size = .{
.dim = .{ .x = 5 },
.grow = .vertical,
},
.rectangle = .{ .fill = .grey },
}, .{}));
try container.append(try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .red },
}, .{}));
defer container.deinit();
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, @TypeOf(container), &container, Model, @import("test/container/fixed_grow_horizontal.zon"));
}