// 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(); 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 => this.properties.size.dim, .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")); }