feat(model): implement Elm architecture
All checks were successful
Zig Project Action / Lint, Spell-check and test zig project (push) Successful in 1m2s

Now the `App` contains a state which is a user-defined `struct` which
is passed to the `handle` and `contents` callbacks for `Container`'s and
`Element`'s. Built-in `Element`'s shall not access the `App.Model` and
should therefore never cause any side-effects.

User-defined events shall be used to act as *messages* to cause
potential side-effects for the model. This is the reason why only
the `handle` callback has a non-const pointer to the `App.Model`. The
`contents` callback can only access the `App.Model` read-only to use for
generating the *view* (in context of the elm architecture).
This commit is contained in:
2025-10-26 15:58:07 +01:00
parent 8f90f57f44
commit feae9fa1a4
22 changed files with 396 additions and 319 deletions

View File

@@ -80,8 +80,9 @@ pub const Border = packed struct {
test "all sides" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .green,
.sides = .all,
@@ -92,14 +93,15 @@ pub const Border = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/border.all.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .green,
.sides = .vertical,
@@ -110,14 +112,15 @@ pub const Border = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/border.vertical.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .green,
.sides = .horizontal,
@@ -128,7 +131,7 @@ pub const Border = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/border.horizontal.zon"));
}, @TypeOf(container), &container, Model, @import("test/container/border.horizontal.zon"));
}
};
@@ -160,8 +163,9 @@ pub const Rectangle = packed struct {
test "fill color overwrite parent fill" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{ .fill = .green },
}, .{});
try container.append(try .init(std.testing.allocator, .{
@@ -173,14 +177,15 @@ pub const Rectangle = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/rectangle_with_parent_fill_without_padding.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.padding = .all(2),
},
@@ -195,14 +200,15 @@ pub const Rectangle = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/rectangle_with_parent_padding.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.padding = .{
.top = -18,
@@ -222,14 +228,15 @@ pub const Rectangle = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/rectangle_with_parent_padding.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{
.fill = .black,
},
@@ -250,14 +257,15 @@ pub const Rectangle = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/rectangle_with_padding.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{
.fill = .black,
},
@@ -281,14 +289,15 @@ pub const Rectangle = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/rectangle_with_gap.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.rectangle = .{
.fill = .black,
},
@@ -313,7 +322,7 @@ pub const Rectangle = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/rectangle_with_separator.zon"));
}, @TypeOf(container), &container, Model, @import("test/container/rectangle_with_separator.zon"));
}
};
@@ -403,8 +412,9 @@ pub const Layout = packed struct {
test "separator without gaps" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.separator = .{
.enabled = true,
@@ -418,14 +428,15 @@ pub const Layout = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/separator_no_gaps.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.padding = .all(1),
.separator = .{
@@ -440,14 +451,15 @@ pub const Layout = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/separator_no_gaps_with_padding.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{
.direction = .vertical,
.separator = .{
@@ -464,14 +476,15 @@ pub const Layout = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/separator_2x_no_gaps.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
@@ -491,14 +504,15 @@ pub const Layout = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/separator_2x_no_gaps_with_border.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
@@ -519,14 +533,15 @@ pub const Layout = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/separator_2x_no_gaps_with_padding.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
@@ -547,14 +562,15 @@ pub const Layout = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/separator_2x_with_gaps_with_border.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.border = .{
.color = .red,
.sides = .all,
@@ -576,7 +592,7 @@ pub const Layout = packed struct {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/separator_2x_with_gaps_with_border_with_padding.zon"));
}, @TypeOf(container), &container, Model, @import("test/container/separator_2x_with_gaps_with_border_with_padding.zon"));
}
};
@@ -591,10 +607,10 @@ pub const Size = packed struct {
} = .both,
};
pub fn Container(comptime Event: type) type {
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(Event);
const Element = @import("element.zig").Element(Model, Event);
return struct {
allocator: Allocator,
origin: Point,
@@ -889,7 +905,7 @@ pub fn Container(comptime Event: type) type {
this.grow_resize(this.size);
}
pub fn handle(this: *const @This(), event: Event) !void {
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
@@ -897,17 +913,14 @@ pub fn Container(comptime Event: type) type {
var relative_mouse: input.Mouse = mouse;
relative_mouse.x -= this.origin.x;
relative_mouse.y -= this.origin.y;
try this.element.handle(.{ .mouse = relative_mouse });
for (this.elements.items) |*element| try element.handle(event);
},
else => {
try this.element.handle(event);
for (this.elements.items) |*element| try element.handle(event);
try this.element.handle(model, .{ .mouse = relative_mouse });
},
else => try this.element.handle(model, event),
}
for (this.elements.items) |*element| try element.handle(model, event);
}
pub fn content(this: *const @This()) ![]Cell {
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));
@@ -918,7 +931,7 @@ pub fn Container(comptime Event: type) type {
this.properties.border.content(cells, this.size);
this.properties.rectangle.content(cells, this.size);
try this.element.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) {
@@ -962,8 +975,9 @@ test {
test "Container Fixed and Grow Size Vertical" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(event.SystemEvent) = try .init(std.testing.allocator, .{
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{
.layout = .{ .direction = .vertical },
}, .{});
try container.append(try .init(std.testing.allocator, .{
@@ -981,14 +995,15 @@ test "Container Fixed and Grow Size Vertical" {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/fixed_grow_vertical.zon"));
}, @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(event.SystemEvent) = try .init(std.testing.allocator, .{}, .{});
var container: Container(Model, event.SystemEvent) = try .init(std.testing.allocator, .{}, .{});
try container.append(try .init(std.testing.allocator, .{
.size = .{
.dim = .{ .x = 5 },
@@ -1004,5 +1019,5 @@ test "Container Fixed and Grow Size Horizontal" {
try testing.expectContainerScreen(.{
.y = 20,
.x = 30,
}, &container, @import("test/container/fixed_grow_horizontal.zon"));
}, @TypeOf(container), &container, Model, @import("test/container/fixed_grow_horizontal.zon"));
}