//! 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); }