Files
zterm/src/element.zig
Yves Biener 8ebab702ac
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 1m26s
fix(element/scrollable): reduce the number of minSize calls and correctly resize scrollable Container
2025-11-05 18:33:16 +01:00

1777 lines
75 KiB
Zig

//! Interface for Element's which describe the contents of a `Container`.
// TODO following features need to be implemented here
// - support for dynamic layouts? it can already be done, but the way you do this is pretty annoying
// -> currently you need to swap out the entire `Container` accordingly before rendering
// -> this might be necessary for size depending layout options, etc.
// -> maybe this can be implemented / supported similarly as to how the scrollable `Element`'s are implemented
// FIX known issues:
// - hold fewer instances of the `Allocator`
pub fn Element(Model: type, Event: type) type {
return struct {
ptr: *anyopaque = undefined,
vtable: *const VTable = &.{},
pub const VTable = struct {
minSize: ?*const fn (ctx: *anyopaque, size: Point) Point = null,
resize: ?*const fn (ctx: *anyopaque, size: Point) void = null,
reposition: ?*const fn (ctx: *anyopaque, origin: Point) void = null,
handle: ?*const fn (ctx: *anyopaque, model: *Model, event: Event) anyerror!void = null,
content: ?*const fn (ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) anyerror!void = null,
};
/// Request the minimal size required for this `Element` the provided
/// available *size* can be used as a fallback. This function ensures
/// that the minimal size returned has at least the dimensions of the
/// available *size*.
///
/// If the associated `Container`'s size is known at compile time (and
/// does not change) the size can be directly provided through the
/// `.size` Property for the `Container` instead. In this case you
/// should not implement / use this function.
///
/// Currently only used for `Scrollable` elements, such that the
/// directly nested element in the `Scrollable`'s `Container` can
/// request a minimal size for its contents.
pub inline fn minSize(this: @This(), size: Point) Point {
if (this.vtable.minSize) |minSize_fn| {
const min_size = minSize_fn(this.ptr, size);
return .{
.x = @max(size.x, min_size.x),
.y = @max(size.y, min_size.y),
};
} else return size;
}
/// Resize the corresponding `Element` with the given *size*.
pub inline fn resize(this: @This(), size: Point) void {
if (this.vtable.resize) |resize_fn|
resize_fn(this.ptr, size);
}
/// Reposition the corresponding `Element` with the given *origin*.
pub inline fn reposition(this: @This(), origin: Point) void {
if (this.vtable.reposition) |reposition_fn|
reposition_fn(this.ptr, origin);
}
/// Handle the received event. The event is one of the user provided
/// events or a system event. The model can be updated through the
/// provided pointer.
///
/// In case of user errors this function should return an error. This
/// error may then be used by the application to display information
/// about the error to the user and is left intentionally up to the
/// implementation to decide.
pub inline fn handle(this: @This(), model: *Model, event: Event) !void {
if (this.vtable.handle) |handle_fn|
try handle_fn(this.ptr, model, event);
}
/// Write content into the `cells` of the `Container`. The associated
/// `cells` slice has the size of (`size.x * size.y`). The
/// renderer will know where to place the contents on the screen.
///
/// # Note
///
/// - Caller owns `cells` slice and ensures the size usually by assertion:
/// ```zig
/// std.debug.assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
/// ```
///
/// - This function should only fail with an error if the error is
/// non-recoverable (i.e. an allocation error, system error, etc.).
/// Otherwise user specific errors should be caught using the `handle`
/// function before the rendering of the `Container` happens.
///
/// - The provided model may be used as a read-only reference to create
/// the contents for the `cells`. Changes should only be done through
/// the `handle` callback.
///
/// - When facing layout problems it might help to enable the
/// build option for debug rendering, which renders corresponding
/// placeholders.
/// - **e** for `Element`
/// - **c** for `Container`
/// - **s** for `Separator`
/// - **r** for `Rectangle`
pub inline fn content(this: @This(), model: *const Model, cells: []Cell, size: Point) !void {
if (this.vtable.content) |content_fn| {
try content_fn(this.ptr, model, cells, size);
// DEBUG render corresponding top left corner of this `Element` *red*
// - only rendered if the corresponding associated element renders contents into the `Container`
if (comptime build_options.debug) {
cells[0].style.fg = .red;
cells[0].style.bg = .black;
cells[0].cp = 'e'; // 'e' for *element*
}
}
}
};
} // Element(Model: type, Event: type)
pub fn Alignment(Model: type, Event: type) type {
return struct {
/// `Size` of the actual contents that should be aligned.
size: Point = .{},
/// Alignment Configuration to use for aligning the associated contents of this `Element`.
configuration: Configuration,
/// `Container` to render in alignment. It needs to use the sizing options accordingly to be effective.
container: Container(Model, Event),
/// Configuration for Alignment
pub const Configuration = packed struct {
h: Align = .start,
v: Align = .start,
/// Alignment Options for configuration for vertical and horizontal orientations
pub const Align = enum(u2) { start, center, end };
/// Configuration for vertical alignment
pub fn vertical(a: Align) @This() {
return .{ .v = a };
}
/// Configuration for horizontal alignment
pub fn horizontal(a: Align) @This() {
return .{ .h = a };
}
pub const center: @This() = .{ .v = .center, .h = .center };
};
pub fn init(container: Container(Model, Event), configuration: Configuration) @This() {
return .{
.container = container,
.configuration = configuration,
};
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.resize = resize,
.reposition = reposition,
.handle = handle,
.content = content,
},
};
}
fn resize(ctx: *anyopaque, size: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.size = size;
this.container.resize(size);
}
fn reposition(ctx: *anyopaque, anchor: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
var origin = anchor;
origin.x = switch (this.configuration.h) {
.start => origin.x,
.center => origin.x + (this.size.x / 2) -| (this.container.size.x / 2),
.end => this.size.x -| this.container.size.x,
};
origin.y = switch (this.configuration.v) {
.start => origin.y,
.center => origin.y + (this.size.y / 2) -| (this.container.size.y / 2),
.end => this.size.y -| this.container.size.y,
};
this.container.reposition(origin);
}
fn handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
try this.container.handle(model, event);
}
fn content(ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const origin = this.container.origin;
const csize = this.container.size;
const container_cells = try this.container.content(model);
defer this.container.allocator.free(container_cells);
outer: for (0..csize.y) |row| {
inner: for (0..csize.x) |col| {
// do not read/write out of bounce
if (this.size.x < row) break :outer;
if (this.size.y < col) break :inner;
cells[((row + origin.y) * size.x) + col + origin.x] = container_cells[(row * csize.x) + col];
}
}
}
};
} // Alignment(Model: type, Event: type)
pub fn Scrollable(Model: type, Event: type) type {
return struct {
/// `Size` of the actual contents where the anchor and the size is
/// representing the size and location on screen.
size: Point = .{},
/// `Size` of the `Container` content that is scrollable and mapped to
/// the *size* of the `Scrollable` `Element`.
container_size: Point = .{},
/// Anchor of the viewport of the scrollable `Container`.
anchor: Point = .{},
/// The actual `Container`, that is scrollable.
container: Container(Model, Event),
/// Whether the scrollable contents should show a scroll bar or not.
/// The scroll bar will only be shown if required and enabled. With the
/// corresponding provided color if to be shown.
configuration: Configuration,
pub const Configuration = packed struct {
scrollbar: bool,
/// Primary color to be used for the scrollbar (and background if enabled)
color: Color = .default,
/// With a backgroud the `color` rendered with emphasis `.dim`
/// will be used to highlight the scrollbar from the background;
/// otherwise nothing is shown for the background
show_background: bool = false,
/// should the scrollbar be shown for the x-axis (horizontal)
x_axis: bool = false,
/// should the scrollbar be shown for the y-axis (vertical)
y_axis: bool = false,
pub const disabled: @This() = .{ .scrollbar = false };
pub fn enabled(color: Color, show_background: bool) @This() {
return .{
.scrollbar = true,
.color = color,
.show_background = show_background,
};
}
};
pub fn init(container: Container(Model, Event), configuration: Configuration) @This() {
return .{
.container = container,
.configuration = configuration,
};
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.resize = resize,
.reposition = reposition,
.handle = handle,
.content = content,
},
};
}
fn resize(ctx: *anyopaque, size: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
const last_max_anchor_x = this.container_size.x -| this.size.x;
const last_max_anchor_y = this.container_size.y -| this.size.y;
// NOTE `container_size` denotes the `size` required for the container contents
var container_size = this.container.element.minSize(size);
if (this.configuration.scrollbar) {
this.configuration.y_axis = this.container.properties.size.dim.x > size.x or container_size.x > size.x;
this.configuration.x_axis = this.container.properties.size.dim.y > size.y or container_size.y > size.y;
// correct size dimensions due to space needed for the axis (if any)
container_size.x -= if (this.configuration.x_axis) 1 else 0;
container_size.y -= if (this.configuration.y_axis) 1 else 0;
}
const new_max_anchor_x = container_size.x -| size.x;
const new_max_anchor_y = container_size.y -| size.y;
// correct anchor if necessary
if (new_max_anchor_x < last_max_anchor_x and this.anchor.x > new_max_anchor_x) this.anchor.x = new_max_anchor_x;
if (new_max_anchor_y < last_max_anchor_y and this.anchor.y > new_max_anchor_y) this.anchor.y = new_max_anchor_y;
this.size = size;
this.container.resize(container_size); // notify the container about the minimal size it should have
this.container_size = container_size;
}
fn reposition(ctx: *anyopaque, _: Point) void {
const this: *@This() = @ptrCast(@alignCast(ctx));
this.container.reposition(.{});
}
fn handle(ctx: *anyopaque, model: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
// TODO what about multiple scrollable `Element` usages? mouse
// can differ between them (due to the event being only send to
// the `Container` / `Element` one under the cursor!)
.key => |key| {
if (key.eql(.{ .cp = 'j' }) or key.eql(.{ .cp = input.Down })) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + 1, max_anchor_y);
}
if (key.eql(.{ .cp = 'k' }) or key.eql(.{ .cp = input.Up })) {
this.anchor.y -|= 1;
}
if (key.eql(.{ .cp = 'd', .mod = .{ .ctrl = true } })) {
// half page down
const half_page = this.size.y / 2;
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + half_page, max_anchor_y);
}
if (key.eql(.{ .cp = 'u', .mod = .{ .ctrl = true } })) {
// half page up
const half_page = this.size.y / 2;
this.anchor.y -|= half_page;
}
if (key.eql(.{ .cp = 'f', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.PageDown })) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + this.size.y, max_anchor_y);
}
if (key.eql(.{ .cp = 'b', .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.PageDown })) {
this.anchor.y -|= this.size.y;
}
if (key.eql(.{ .cp = 'g' }) or key.eql(.{ .cp = input.Home })) {
this.anchor.y = 0;
}
if (key.eql(.{ .cp = 'G' }) or key.eql(.{ .cp = input.End })) {
this.anchor.y = this.container_size.y -| this.size.y;
}
},
.mouse => |mouse| switch (mouse.button) {
Mouse.Button.wheel_up => if (this.container_size.y > this.size.y) {
this.anchor.y -|= 1;
},
Mouse.Button.wheel_down => if (this.container_size.y > this.size.y) {
const max_anchor_y = this.container_size.y -| this.size.y;
this.anchor.y = @min(this.anchor.y + 1, max_anchor_y);
},
Mouse.Button.wheel_left => if (this.container_size.x > this.size.x) {
this.anchor.x -|= 1;
},
Mouse.Button.wheel_right => if (this.container_size.x > this.size.x) {
const max_anchor_x = this.container_size.x -| this.size.x;
this.anchor.x = @min(this.anchor.x + 1, max_anchor_x);
},
else => try this.container.handle(model, .{
.mouse = .{
.x = mouse.x + this.anchor.x,
.y = mouse.y + this.anchor.y,
.button = mouse.button,
.kind = mouse.kind,
},
}),
},
else => try this.container.handle(model, event),
}
}
fn render_container(container: Container(Model, Event), model: *const Model, cells: []Cell, container_size: Point) !void {
const size = container.size;
const origin = container.origin;
const contents = try container.content(model);
const anchor = (@as(usize, origin.y) * @as(usize, container_size.x)) + @as(usize, origin.x);
var idx: usize = 0;
blk: for (0..size.y) |row| {
for (0..size.x) |col| {
cells[anchor + (row * container_size.x) + col] = contents[idx];
idx += 1;
if (contents.len == idx) break :blk;
}
}
// free immediately
container.allocator.free(contents);
for (container.elements.items) |child| try render_container(child, model, cells, size);
}
fn content(ctx: *anyopaque, model: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
assert(cells.len == @as(usize, this.size.x) * @as(usize, this.size.y));
const offset_x: usize = if (this.configuration.x_axis) 1 else 0;
const offset_y: usize = if (this.configuration.y_axis) 1 else 0;
const container_size = this.container.size;
const container_cells = try this.container.content(model);
for (this.container.elements.items) |child| try render_container(child, model, container_cells, container_size);
const anchor = (@as(usize, this.anchor.y) * @as(usize, container_size.x)) + @as(usize, this.anchor.x);
for (0..size.y - offset_y) |row| {
for (0..size.x - offset_x) |col| {
cells[(row * size.x) + col] = container_cells[anchor + (row * container_size.x) + col];
}
}
if (this.configuration.scrollbar) {
if (this.configuration.x_axis) {
const ratio: f32 = @as(f32, @floatFromInt(this.size.y)) / @as(f32, @floatFromInt(this.container.size.y));
const scrollbar_size: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.y)) * ratio);
const pos_ratio: f32 = @as(f32, @floatFromInt(this.anchor.y)) / @as(f32, @floatFromInt(this.container.size.y));
const scrollbar_starting_pos: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.y)) * pos_ratio);
for (0..size.y) |row| {
cells[(row * size.x) + size.x - 1] = .{
.style = .{
.fg = this.configuration.color,
.emphasis = if (row >= scrollbar_starting_pos and row <= scrollbar_starting_pos + scrollbar_size)
&.{} // scrollbar itself
else if (this.configuration.show_background)
&.{.dim} // background (around scrollbar)
else
&.{.hidden}, // hide background
},
.cp = '🮋', // 7/8 block right
};
}
}
if (this.configuration.y_axis) {
const ratio: f32 = @as(f32, @floatFromInt(this.size.x)) / @as(f32, @floatFromInt(this.container.size.x));
const scrollbar_size: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.x)) * ratio);
const pos_ratio: f32 = @as(f32, @floatFromInt(this.anchor.x)) / @as(f32, @floatFromInt(this.container.size.x));
const scrollbar_starting_pos: usize = @intFromFloat(@as(f32, @floatFromInt(this.size.x)) * pos_ratio);
for (0..size.x) |col| {
cells[((size.y - 1) * size.x) + col] = .{
.style = .{
.fg = this.configuration.color,
.emphasis = if (col >= scrollbar_starting_pos and col <= scrollbar_starting_pos + scrollbar_size)
&.{} // scrollbar itself
else if (this.configuration.show_background)
&.{.dim} // background (around scrollbar)
else
&.{.hidden}, // hide background
},
.cp = '🮄', // 5/8 block top (to make it look more equivalent to the vertical scrollbar)
};
}
}
}
this.container.allocator.free(container_cells);
}
};
} // Scrollable(Model: type, Event: type)
// TODO features
// - clear input (with and without retaining of capacity) through an public api
// - make handle / content functions public
pub fn Input(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) type {
// NOTE the struct is necessary, as otherwise I cannot point to the function I want to return
const input_struct = struct {
pub fn input_fn(accept_event: meta.FieldEnum(Event)) type {
const event_type: enum { ascii, utf8 } = blk: { // check for type correctness and the associated type to use for the passed `accept_event`
const err_msg = "Unexpected type for the associated input completion event to trigger. Only `[]u8` or `[]u21` are allowed.";
switch (@typeInfo(@FieldType(Event, @tagName(accept_event)))) {
.pointer => |pointer| {
if (pointer.size != .slice) @compileError(err_msg);
switch (@typeInfo(pointer.child)) {
.int => |num| {
if (num.signedness != .unsigned) @compileError(err_msg);
switch (num.bits) {
8 => break :blk .ascii,
21 => break :blk .utf8,
else => @compileError(err_msg),
}
},
else => @compileError(err_msg),
}
},
else => @compileError(err_msg),
}
};
return struct {
allocator: std.mem.Allocator,
/// Offset from the end describing the current position of the cursor.
cursor_offset: usize = 0,
/// Configuration for the InputField.
configuration: Configuration,
/// Array holding the value of the input.
input: std.ArrayList(u21),
/// Reference to the app's queue to issue the associated event to trigger when completing the input.
queue: *Queue,
/// Configuration for InputField's.
pub const Configuration = packed struct {
color: Color,
pub fn init(color: Color) @This() {
return .{ .color = color };
}
};
pub fn init(allocator: std.mem.Allocator, queue: *Queue, configuration: Configuration) @This() {
return .{
.allocator = allocator,
.configuration = configuration,
.input = std.ArrayList(u21).initCapacity(allocator, 8) catch unreachable,
.queue = queue,
};
}
pub fn deinit(this: *@This()) void {
this.input.deinit(this.allocator);
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
fn handle(ctx: *anyopaque, _: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.key => |key| {
assert(this.cursor_offset >= 0 and this.cursor_offset <= this.input.items.len);
// readline commands
if (key.eql(.{ .cp = input.Left }) or key.eql(.{ .cp = input.KpLeft }) or key.eql(.{ .cp = 'b', .mod = .{ .ctrl = true } })) {
if (this.cursor_offset < this.input.items.len) this.cursor_offset += 1;
}
if (key.eql(.{ .cp = input.Right }) or key.eql(.{ .cp = input.KpRight }) or key.eql(.{ .cp = 'f', .mod = .{ .ctrl = true } }))
this.cursor_offset -|= 1;
if (key.eql(.{ .cp = 'e', .mod = .{ .ctrl = true } })) this.cursor_offset = 0;
if (key.eql(.{ .cp = 'a', .mod = .{ .ctrl = true } })) this.cursor_offset = this.input.items.len;
if (key.eql(.{ .cp = 'k', .mod = .{ .ctrl = true } })) {
while (this.cursor_offset > 0) {
_ = this.input.pop();
this.cursor_offset -= 1;
}
}
if (key.eql(.{ .cp = 'u', .mod = .{ .ctrl = true } })) {
const len = this.input.items.len - this.cursor_offset;
for (0..len) |_| _ = this.input.orderedRemove(0);
this.cursor_offset = this.input.items.len;
}
if (key.eql(.{ .cp = 'w', .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset < this.input.items.len) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') break;
// see backspace
const removed = if (this.cursor_offset == 0) this.input.pop() else this.input.orderedRemove(this.input.items.len - this.cursor_offset - 1);
if (removed != ' ') non_whitespace = true;
}
}
if (key.eql(.{ .cp = 'b', .mod = .{ .alt = true } }) or key.eql(.{ .cp = input.Left, .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.KpLeft, .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset < this.input.items.len) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') break;
// see backspace
this.cursor_offset += 1;
if (this.cursor_offset == this.input.items.len) break;
const next = this.input.items[this.input.items.len - this.cursor_offset - 1];
if (next != ' ') non_whitespace = true;
}
}
if (key.eql(.{ .cp = 'f', .mod = .{ .alt = true } }) or key.eql(.{ .cp = input.Right, .mod = .{ .ctrl = true } }) or key.eql(.{ .cp = input.KpRight, .mod = .{ .ctrl = true } })) {
var non_whitespace = false;
while (this.cursor_offset > 0) {
if (non_whitespace and this.input.items[this.input.items.len - this.cursor_offset - 1] == ' ') {
this.cursor_offset += 1; // correct cursor position back again to make sure the cursor is not on the whitespace, but at the end of the jumped word
break;
}
// see backspace
this.cursor_offset -= 1;
const next = this.input.items[this.input.items.len - this.cursor_offset - 1];
if (next != ' ') non_whitespace = true;
}
}
// usual input keys
if (key.isAscii()) try this.input.insert(this.allocator, this.input.items.len - this.cursor_offset, key.cp);
if (key.eql(.{ .cp = input.Backspace })) {
if (this.cursor_offset < this.input.items.len) {
_ = if (this.cursor_offset == 0) this.input.pop() else this.input.orderedRemove(this.input.items.len - this.cursor_offset - 1);
}
}
if (key.eql(.{ .cp = input.Delete }) or key.eql(.{ .cp = input.KpDelete })) {
if (this.cursor_offset > 0) {
_ = this.input.orderedRemove(this.input.items.len - this.cursor_offset);
this.cursor_offset -= 1;
}
}
// TODO enter to accept?
// - shift+enter is not recognized by the input reader of `zterm`, so currently it is not possible to add newlines into the text box?
if (key.eql(.{ .cp = input.Enter }) or key.eql(.{ .cp = input.KpEnter })) {
switch (event_type) {
.ascii => {
// NOTE convert unicode characters to ascii characters; if non ascii characters are found this is will fail!
var slice = try this.allocator.alloc(u8, this.input.items.len);
for (0.., this.input.items) |i, c| slice[i] = @intCast(c);
this.input.clearAndFree(this.allocator);
this.queue.push(@unionInit(
Event,
@tagName(accept_event),
slice,
));
},
.utf8 => this.queue.push(@unionInit(
Event,
@tagName(accept_event),
try this.input.toOwnedSlice(this.allocator),
)),
}
this.cursor_offset = 0;
}
},
else => {},
}
}
fn content(ctx: *anyopaque, _: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const offset = if (this.input.items.len - this.cursor_offset + 1 >= cells.len) this.input.items.len + 1 - cells.len else 0;
for (this.input.items[offset..], 0..) |cp, idx| {
cells[idx].style.fg = this.configuration.color;
cells[idx].cp = cp;
// display ellipse at the beginning
if (offset > 0 and idx == 0) cells[idx].cp = '…';
// NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len - 1) {
// display ellipse at the end
if (this.input.items.len >= cells.len and this.cursor_offset > 0) cells[idx].cp = '…';
break;
}
}
if (this.input.items.len < cells.len)
cells[this.input.items.len - this.cursor_offset].style.cursor = true
else
cells[this.input.items.len - offset - this.cursor_offset].style.cursor = true;
}
};
}
};
return input_struct.input_fn;
} // Input(Model: type, Event: type, Queue: type)
pub fn Button(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) type {
// NOTE the struct is necessary, as otherwise I cannot point to the function I want to return
const button_struct = struct {
pub fn button_fn(accept_event: meta.FieldEnum(Event)) type {
{ // check for type correctness and the associated type to use for the passed `accept_event`
const err_msg = "Unexpected type for the associated input completion event to trigger. Only `void` is allowed.";
// TODO supported nested tagged unions to be also be used for triggering if the enum is then still of `void`type!
switch (@typeInfo(@FieldType(Event, @tagName(accept_event)))) {
.void => |_| {},
else => @compileError(err_msg),
}
}
return struct {
queue: *Queue,
configuration: Configuration,
/// Configuration for InputField's.
pub const Configuration = struct {
color: Color,
text: []const u8,
pub fn init(color: Color, text: []const u8) @This() {
return .{
.color = color,
.text = text,
};
}
};
pub fn init(queue: *Queue, configuration: Configuration) @This() {
return .{
.queue = queue,
.configuration = configuration,
};
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
fn handle(ctx: *anyopaque, _: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
// TODO should this also support key presses to accept?
.mouse => |mouse| if (mouse.button == .left and mouse.kind == .release) this.queue.push(accept_event),
else => {},
}
}
fn content(ctx: *anyopaque, _: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
// NOTE center text in the middle of the available cell slice
const row = size.y / 2 -| (this.configuration.text.len / 2);
const col = size.x / 2 -| (this.configuration.text.len / 2);
const anchor = (row * size.x) + col;
for (0.., this.configuration.text) |idx, cp| {
cells[anchor + idx].style.fg = this.configuration.color;
cells[anchor + idx].style.emphasis = &.{.bold};
cells[anchor + idx].cp = cp;
// NOTE do not write over the contents of this `Container`'s `Size`
if (anchor + idx == cells.len - 1) break;
}
}
};
}
};
return button_struct.button_fn;
} // Button(Model: type, Event: type, Queue: type)
pub fn RadioButton(Model: type, Event: type) type {
return struct {
configuration: Configuration,
value: bool,
pub const Configuration = struct {
// TODO support more user control for colors (i.e. background, foreground, checked, unchecked, etc.)
color: Color = .default,
style: enum(u1) {
squared,
rounded,
} = .rounded,
label: []const u8,
};
pub fn init(initial_value: bool, configuration: Configuration) @This() {
return .{
.value = initial_value,
.configuration = configuration,
};
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
fn handle(ctx: *anyopaque, _: *Model, event: Event) !void {
var this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
// TODO should this also support key presses to accept?
.mouse => |mouse| if (mouse.button == .left and mouse.kind == .release) {
this.value = !this.value;
},
else => {},
}
}
fn content(ctx: *anyopaque, _: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
cells[0].cp = switch (this.configuration.style) {
.rounded => if (this.value) '●' else '○',
.squared => if (this.value) '■' else '□',
};
cells[0].style.fg = this.configuration.color;
for (2.., this.configuration.label) |idx, cp| {
cells[idx].style.fg = this.configuration.color;
cells[idx].cp = cp;
// NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len - 1) break;
}
}
};
} // RadioButton(Model: type, Event: type)
pub fn Selection(Model: type, Event: type) fn (type) type {
const selection_struct = struct {
pub fn selection_fn(Enum: type) type {
switch (@typeInfo(Enum)) {
.@"enum" => |e| if (!e.is_exhaustive) @compileError("Selection's enum value needs to be exhaustive."),
else => @compileError("Selection's `Enum` type is not an `enum` type."),
}
return struct {
value: Enum,
configuration: Configuration,
width: u16,
pub const Configuration = struct {
label: []const u8,
};
pub fn init(configuration: Configuration) @This() {
var max_len: usize = 0;
inline for (std.meta.fields(Enum)) |field| max_len = @max(max_len, field.name.len);
return .{
.value = @enumFromInt(0),
.configuration = configuration,
.width = @intCast(max_len),
};
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
fn handle(ctx: *anyopaque, _: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
switch (event) {
.mouse => |mouse| if (mouse.y == 0) {
if (mouse.button == .left and mouse.kind == .release) {
// left button
if (mouse.x == this.configuration.label.len + 1) {
const next = if (@intFromEnum(this.value) > 0)
@intFromEnum(this.value) - 1
else
std.meta.fields(Enum).len - 1;
this.value = @enumFromInt(next);
}
// right button
if (mouse.x == this.configuration.label.len + 4 + this.width) {
const next = if (@intFromEnum(this.value) < std.meta.fields(Enum).len - 1)
@intFromEnum(this.value) + 1
else
0;
this.value = @enumFromInt(next);
}
}
if (mouse.x > this.configuration.label.len and mouse.x < this.configuration.label.len + 4 + this.width) {
if (mouse.button == .wheel_down) {
const next = if (@intFromEnum(this.value) > 0)
@intFromEnum(this.value) - 1
else
std.meta.fields(Enum).len - 1;
this.value = @enumFromInt(next);
}
if (mouse.button == .wheel_up) {
const next = if (@intFromEnum(this.value) < std.meta.fields(Enum).len - 1)
@intFromEnum(this.value) + 1
else
0;
this.value = @enumFromInt(next);
}
}
},
.key => |key| {
if (key.eql(.{ .cp = 'j' }) or key.eql(.{ .cp = input.Down }) or key.eql(.{ .cp = input.KpDown })) {
const next = if (@intFromEnum(this.value) < std.meta.fields(Enum).len - 1)
@intFromEnum(this.value) + 1
else
0;
this.value = @enumFromInt(next);
}
if (key.eql(.{ .cp = 'k' }) or key.eql(.{ .cp = input.Up }) or key.eql(.{ .cp = input.KpUp })) {
const next = if (@intFromEnum(this.value) > 0)
@intFromEnum(this.value) - 1
else
std.meta.fields(Enum).len - 1;
this.value = @enumFromInt(next);
}
if (key.eql(.{ .cp = 'h' }) or key.eql(.{ .cp = input.Left }) or key.eql(.{ .cp = input.KpLeft })) {
const next = if (@intFromEnum(this.value) > 0)
@intFromEnum(this.value) - 1
else
std.meta.fields(Enum).len - 1;
this.value = @enumFromInt(next);
}
if (key.eql(.{ .cp = 'l' }) or key.eql(.{ .cp = input.Right }) or key.eql(.{ .cp = input.KpRight })) {
const next = if (@intFromEnum(this.value) < std.meta.fields(Enum).len - 1)
@intFromEnum(this.value) + 1
else
0;
this.value = @enumFromInt(next);
}
},
else => {},
}
}
fn content(ctx: *anyopaque, _: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
if (this.configuration.label.len + 4 + this.width >= cells.len) return Error.TooSmall;
for (0.., this.configuration.label) |i, c| {
if (i == cells.len - 1) break;
cells[i].cp = c;
}
cells[this.configuration.label.len + 1].cp = '◀';
const value = @tagName(this.value);
const offset = (this.width - value.len) / 2; // to center the value's text
for (this.configuration.label.len + 3 + offset.., value) |i, c| {
if (i == cells.len - 1) break;
cells[i].cp = c;
}
cells[this.configuration.label.len + 4 + this.width].cp = '▶';
}
};
}
};
return selection_struct.selection_fn;
} // Selection(Model: type, Event: type)
pub fn Progress(Model: type, Event: type, Queue: type) fn (meta.FieldEnum(Event)) type {
// NOTE the struct is necessary, as otherwise I cannot point to the function I want to return
const progress_struct = struct {
pub fn progress_fn(progress_event: meta.FieldEnum(Event)) type {
{ // check for type correctness and the associated type to use for the passed `progress_event`
const err_msg = "Unexpected type for the associated input completion event to trigger. Only `u8` is allowed.";
switch (@typeInfo(@FieldType(Event, @tagName(progress_event)))) {
.int => |num| {
if (num.signedness != .unsigned) @compileError(err_msg);
switch (num.bits) {
8 => {},
else => @compileError(err_msg),
}
},
else => @compileError(err_msg),
}
}
return struct {
configuration: Configuration,
progress: u8 = 0,
queue: *Queue,
pub const Configuration = packed struct {
/// Control whether the percentage of the progress be rendered.
percent: packed struct {
enabled: bool = false,
alignment: enum(u2) { left, middle, right } = .middle,
} = .{},
/// Foreground color to use for the progress bar.
fg: Color,
/// Background color to use for the progress bar.
bg: Color,
/// Code point to use for rendering the progress bar with.
cp: u21 = '━',
};
pub fn init(queue: *Queue, configuration: Configuration) @This() {
return .{
.configuration = configuration,
.queue = queue,
};
}
pub fn element(this: *@This()) Element(Model, Event) {
return .{
.ptr = this,
.vtable = &.{
.handle = handle,
.content = content,
},
};
}
fn handle(ctx: *anyopaque, _: *Model, event: Event) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
// TODO should this `Element` trigger a completion event? (I don't think that this is useful?)
switch (event) {
progress_event => |value| {
assert(value >= 0 and value <= 100);
this.progress = value;
},
else => {},
}
}
fn content(ctx: *anyopaque, _: *const Model, cells: []Cell, size: Point) !void {
const this: *@This() = @ptrCast(@alignCast(ctx));
assert(cells.len == @as(usize, size.x) * @as(usize, size.y));
const ratio: f32 = @as(f32, @floatFromInt(size.x)) / 100.0;
const scrollbar_end_pos: usize = @intFromFloat(ratio * @as(f32, @floatFromInt(this.progress)));
// NOTE remain `undefined` in case percentage rendering is not enabled
var percent_buf: [6]u8 = undefined;
var percent_buf_len: usize = undefined;
if (this.configuration.percent.enabled) percent_buf_len = (try std.fmt.bufPrint(&percent_buf, "[{d: >3}%]", .{this.progress})).len;
for (0..size.x) |idx| {
// NOTE the progress bar is rendered at the bottom row of the `Container` taking its entire columns
cells[((size.y - 1) * size.x) + idx] = .{
.style = .{
.fg = if (scrollbar_end_pos > 0 and idx <= scrollbar_end_pos) this.configuration.fg else this.configuration.bg,
.emphasis = &.{},
},
.cp = if (this.configuration.percent.enabled) switch (this.configuration.percent.alignment) {
.left => if (idx < percent_buf_len) percent_buf[idx] else this.configuration.cp,
.middle => if (idx >= ((size.x - percent_buf_len) / 2) and idx < ((size.x - percent_buf_len) / 2) + percent_buf_len) percent_buf[percent_buf_len - (((size.x - percent_buf_len) / 2) + percent_buf_len - idx - 1) - 1] else this.configuration.cp,
.right => if (idx >= size.x - percent_buf_len) percent_buf[percent_buf_len - ((size.x - idx + percent_buf_len - 1) % percent_buf_len) - 1] else this.configuration.cp,
} else this.configuration.cp,
};
// NOTE do not write over the contents of this `Container`'s `Size`
if (idx == cells.len - 1) break;
}
}
};
}
};
return progress_struct.progress_fn;
} // Progress(Model: type, Event: type, Queue: type)
const std = @import("std");
const assert = std.debug.assert;
const meta = std.meta;
const build_options = @import("build_options");
const input = @import("input.zig");
const Container = @import("container.zig").Container;
const Cell = @import("cell.zig");
const Color = @import("color.zig").Color;
const Error = @import("error.zig").Error;
const Mouse = input.Mouse;
const Point = @import("point.zig").Point;
// TODO nested scrollable `Container`s?'
test "scrollable vertical" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const allocator = std.testing.allocator;
const size: Point = .{
.x = 30,
.y = 20,
};
const Model = struct {};
var model: Model = .{};
var box: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.sides = .all,
.color = .red,
},
.layout = .{
.separator = .{
.enabled = true,
.color = .red,
},
.direction = .vertical,
.padding = .all(1),
},
.size = .{
.dim = .{ .y = size.y + 15 },
},
}, .{});
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .disabled);
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.color = .green,
.sides = .vertical,
},
}, scrollable.element());
defer container.deinit();
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.top.zon"), renderer.screen);
// scroll down 15 times (exactly to the end) (with both mouse and key inputs)
for (0..7) |_| try container.handle(&model, .{
.mouse = .{
.button = .wheel_down,
.kind = .press,
.x = 5,
.y = 5,
},
});
for (7..15) |_| try container.handle(&model, .{
.key = .{ .cp = 'j' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// further scrolling down will not change anything
try container.handle(&model, .{
.mouse = .{
.button = .wheel_down,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling up and down again
for (0..5) |_| try container.handle(&model, .{
.key = .{ .cp = 'k' },
});
for (0..5) |_| try container.handle(&model, .{
.key = .{ .cp = 'j' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling half page up and down again
try container.handle(&model, .{
.key = .{ .cp = 'u', .mod = .{ .ctrl = true } },
});
try container.handle(&model, .{
.key = .{ .cp = 'd', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
// scrolling page up
try container.handle(&model, .{
.key = .{ .cp = 'b', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.top.zon"), renderer.screen);
// and down again
try container.handle(&model, .{
.key = .{ .cp = 'f', .mod = .{ .ctrl = true } },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen);
}
test "scrollable vertical with scrollbar" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const allocator = std.testing.allocator;
const size: Point = .{
.x = 30,
.y = 20,
};
const Model = struct {};
var model: Model = .{};
var box: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.sides = .all,
.color = .red,
},
.layout = .{
.separator = .{
.enabled = true,
.color = .red,
},
.direction = .vertical,
.padding = .all(1),
},
.size = .{
.dim = .{ .y = size.y + 15 },
},
}, .{});
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .enabled(.white, true));
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.color = .green,
.sides = .vertical,
},
}, scrollable.element());
defer container.deinit();
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.scrollbar.top.zon"), renderer.screen);
// scroll down 15 times (exactly to the end)
for (0..15) |_| try container.handle(&model, .{
.mouse = .{
.button = .wheel_down,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.scrollbar.bottom.zon"), renderer.screen);
// further scrolling down will not change anything
try container.handle(&model, .{
.mouse = .{
.button = .wheel_down,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.scrollbar.bottom.zon"), renderer.screen);
}
test "scrollable horizontal" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const allocator = std.testing.allocator;
const size: Point = .{
.x = 30,
.y = 20,
};
const Model = struct {};
var model: Model = .{};
var box: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.sides = .all,
.color = .red,
},
.layout = .{
.separator = .{
.enabled = true,
.color = .red,
},
.direction = .horizontal,
.padding = .all(1),
},
.size = .{
.dim = .{ .x = size.x + 15 },
},
}, .{});
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .disabled);
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.color = .green,
.sides = .horizontal,
},
}, scrollable.element());
defer container.deinit();
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.left.zon"), renderer.screen);
// scroll right 15 times (exactly to the end)
for (0..15) |_| try container.handle(&model, .{
.mouse = .{
.button = .wheel_right,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.right.zon"), renderer.screen);
// further scrolling right will not change anything
try container.handle(&model, .{
.mouse = .{
.button = .wheel_right,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.right.zon"), renderer.screen);
}
test "scrollable horizontal with scrollbar" {
const event = @import("event.zig");
const testing = @import("testing.zig");
const allocator = std.testing.allocator;
const size: Point = .{
.x = 30,
.y = 20,
};
const Model = struct {};
var model: Model = .{};
var box: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.sides = .all,
.color = .red,
},
.layout = .{
.separator = .{
.enabled = true,
.color = .red,
},
.direction = .horizontal,
.padding = .all(1),
},
.size = .{
.dim = .{ .x = size.x + 15 },
},
}, .{});
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
try box.append(try .init(allocator, .{
.rectangle = .{ .fill = .grey },
}, .{}));
defer box.deinit();
var scrollable: Scrollable(Model, event.SystemEvent) = .init(box, .enabled(.white, true));
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.border = .{
.color = .green,
.sides = .horizontal,
},
}, scrollable.element());
defer container.deinit();
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.left.zon"), renderer.screen);
// scroll right 15 times (exactly to the end)
for (0..15) |_| try container.handle(&model, .{
.mouse = .{
.button = .wheel_right,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.right.zon"), renderer.screen);
// further scrolling right will not change anything
try container.handle(&model, .{
.mouse = .{
.button = .wheel_right,
.kind = .press,
.x = 5,
.y = 5,
},
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.right.zon"), renderer.screen);
}
test "alignment center" {
const allocator = std.testing.allocator;
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .center);
try container.append(try .init(allocator, .{}, alignment.element()));
try testing.expectContainerScreen(.{
.x = 30,
.y = 20,
}, @TypeOf(container), &container, Model, @import("test/element/alignment.center.zon"));
}
test "alignment left" {
const allocator = std.testing.allocator;
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
.h = .start,
.v = .center,
});
try container.append(try .init(allocator, .{}, alignment.element()));
try testing.expectContainerScreen(.{
.x = 30,
.y = 20,
}, @TypeOf(container), &container, Model, @import("test/element/alignment.left.zon"));
}
test "alignment right" {
const allocator = std.testing.allocator;
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
.h = .end,
.v = .center,
});
try container.append(try .init(allocator, .{}, alignment.element()));
try testing.expectContainerScreen(.{
.x = 30,
.y = 20,
}, @TypeOf(container), &container, Model, @import("test/element/alignment.right.zon"));
}
test "alignment top" {
const allocator = std.testing.allocator;
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
.h = .center,
.v = .start,
});
try container.append(try .init(allocator, .{}, alignment.element()));
try testing.expectContainerScreen(.{
.x = 30,
.y = 20,
}, @TypeOf(container), &container, Model, @import("test/element/alignment.top.zon"));
}
test "alignment bottom" {
const allocator = std.testing.allocator;
const event = @import("event.zig");
const testing = @import("testing.zig");
const Model = struct {};
var container: Container(Model, event.SystemEvent) = try .init(allocator, .{}, .{});
defer container.deinit();
var aligned_container: Container(Model, event.SystemEvent) = try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 5 },
.grow = .fixed,
},
}, .{});
defer aligned_container.deinit();
var alignment: Alignment(Model, event.SystemEvent) = .init(aligned_container, .{
.h = .center,
.v = .end,
});
try container.append(try .init(allocator, .{}, alignment.element()));
try testing.expectContainerScreen(.{
.x = 30,
.y = 20,
}, @TypeOf(container), &container, Model, @import("test/element/alignment.bottom.zon"));
}
test "input element" {
// FIX correctly generate the `.zon` files for the cell equivalence test (see below)
const allocator = std.testing.allocator;
const event = @import("event.zig");
const Event = event.mergeTaggedUnions(event.SystemEvent, union(enum) {
accept: []u21,
});
const testing = @import("testing.zig");
const Queue = @import("queue.zig").Queue(Event, 256);
const Model = struct {};
var model: Model = .{};
var container: Container(Model, Event) = try .init(allocator, .{}, .{});
defer container.deinit();
const size: Point = .{
.x = 30,
.y = 20,
};
var queue: Queue = .{};
var input_element: Input(Model, Event, Queue)(.accept) = .init(allocator, &queue, .init(.black));
defer input_element.deinit();
const input_container: Container(Model, Event) = try .init(allocator, .{
.rectangle = .{ .fill = .green },
.size = .{
.dim = .{ .x = 12, .y = 2 },
.grow = .fixed,
},
}, input_element.element());
try container.append(input_container);
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/input.without.text.zon"), renderer.screen);
// press 'a' 15 times
for (0..15) |_| try container.handle(&model, .{
.key = .{ .cp = 'a' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/input.with.text.zon"), renderer.screen);
// press 'a' 15 times
for (0..15) |_| try container.handle(&model, .{
.key = .{ .cp = 'a' },
});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/input.with.text.overflow.zon"), renderer.screen);
// test the accepting of the `Element`
try container.handle(&model, .{
.key = .{ .cp = input.Enter },
});
const accept_event = queue.pop();
try std.testing.expectEqual(.accept, std.meta.activeTag(accept_event));
try std.testing.expectEqual(30, switch (accept_event) {
.accept => |input_content| input_content.len,
else => unreachable,
});
// free allocated resources
switch (accept_event) {
.accept => |slice| allocator.free(slice),
else => unreachable,
}
}
test "button" {
const allocator = std.testing.allocator;
const event = @import("event.zig");
const Event = event.mergeTaggedUnions(event.SystemEvent, union(enum) {
accept,
});
const testing = @import("testing.zig");
const Queue = @import("queue.zig").Queue(Event, 256);
const size: Point = .{
.x = 30,
.y = 20,
};
var queue: Queue = .{};
const Model = struct {};
var model: Model = .{};
var container: Container(Model, Event) = try .init(allocator, .{}, .{});
defer container.deinit();
var button: Button(Model, Event, Queue)(.accept) = .init(&queue, .init(.default, "Button"));
const button_container: Container(Model, Event) = try .init(allocator, .{
.rectangle = .{ .fill = .blue },
}, button.element());
try container.append(button_container);
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/button.zon"), renderer.screen);
// test the accepting of the `Element`
try container.handle(&model, .{
.mouse = .{
.x = 5,
.y = 3,
.button = .left,
.kind = .release,
},
});
try std.testing.expect(switch (queue.pop()) {
.accept => true,
else => false,
});
}
// TODO add test cases for `RadioButton` and `Selection`
test "progress" {
const allocator = std.testing.allocator;
const event = @import("event.zig");
const Event = event.mergeTaggedUnions(event.SystemEvent, union(enum) {
progress: u8,
});
const testing = @import("testing.zig");
const Queue = @import("queue.zig").Queue(Event, 256);
const size: Point = .{
.x = 30,
.y = 20,
};
var queue: Queue = .{};
const Model = struct {};
var model: Model = .{};
var container: Container(Model, Event) = try .init(allocator, .{
.layout = .{
.padding = .all(1),
},
.rectangle = .{ .fill = .white },
}, .{});
defer container.deinit();
var progress: Progress(Model, Event, Queue)(.progress) = .init(&queue, .{
.percent = .{ .enabled = true },
.fg = .green,
.bg = .grey,
});
const progress_container: Container(Model, Event) = try .init(allocator, .{}, progress.element());
try container.append(progress_container);
var renderer: testing.Renderer = .init(allocator, size);
defer renderer.deinit();
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_zero.zon"), renderer.screen);
// test the progress of the `Element`
try container.handle(&model, .{
.progress = 25,
});
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_one_quarter.zon"), renderer.screen);
// test the progress of the `Element`
try container.handle(&model, .{
.progress = 50,
});
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_half.zon"), renderer.screen);
// test the progress of the `Element`
try container.handle(&model, .{
.progress = 75,
});
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_three_quarter.zon"), renderer.screen);
// test the progress of the `Element`
try container.handle(&model, .{
.progress = 100,
});
container.resize(size);
container.reposition(.{});
try renderer.render(@TypeOf(container), &container, Model, &.{});
// try testing.expectEqualCells(.{}, renderer.size, @import("test/element/progress_one_hundred.zon"), renderer.screen);
}