//! Interface for Element's which describe the contents of a `Container`. pub fn Element(Event: type) type { return struct { ptr: *anyopaque = undefined, vtable: *const VTable = &.{}, pub const VTable = struct { resize: ?*const fn (ctx: *anyopaque, size: Point) void = null, reposition: ?*const fn (ctx: *anyopaque, origin: Point) void = null, handle: ?*const fn (ctx: *anyopaque, event: Event) anyerror!void = null, content: ?*const fn (ctx: *anyopaque, cells: []Cell, size: Point) anyerror!void = null, }; /// 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, with the exception of the `.size` /// `Event` as every `Container` already handles that event. /// /// 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 user error. pub inline fn handle(this: @This(), event: Event) !void { if (this.vtable.handle) |handle_fn| try handle_fn(this.ptr, 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 that 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. pub inline fn content(this: @This(), cells: []Cell, size: Point) !void { if (this.vtable.content) |content_fn| { try content_fn(this.ptr, 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* } } } }; } pub fn Scrollable(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(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, color: Color = .default, x_axis: bool = false, y_axis: bool = false, pub const disabled: @This() = .{ .scrollbar = false }; pub fn enabled(color: Color) @This() { return .{ .scrollbar = true, .color = color }; } }; pub fn init(container: Container(Event), configuration: Configuration) @This() { return .{ .container = container, .configuration = configuration, }; } pub fn element(this: *@This()) Element(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; this.size = size; this.container.resize(size); if (this.configuration.scrollbar) { if (this.container.properties.size.dim.x > this.size.x or this.container.size.x > this.size.x) this.configuration.y_axis = true; if (this.container.properties.size.dim.y > this.size.y or this.container.size.y > this.size.y) this.configuration.x_axis = true; if (this.configuration.x_axis or this.configuration.y_axis) this.container.resize(.{ .x = this.size.x - if (this.configuration.x_axis) @as(u16, 1) else @as(u16, 0), .y = this.size.y - if (this.configuration.y_axis) @as(u16, 1) else @as(u16, 0), }); } const new_max_anchor_x = this.container.size.x -| size.x; const new_max_anchor_y = this.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.container_size = this.container.size; } fn reposition(ctx: *anyopaque, _: Point) void { const this: *@This() = @ptrCast(@alignCast(ctx)); this.container.reposition(.{}); } fn handle(ctx: *anyopaque, event: Event) !void { const this: *@This() = @ptrCast(@alignCast(ctx)); switch (event) { // TODO other means to scroll except with the mouse? (i.e. Ctrl-u/d, k/j, etc.?) .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(.{ .mouse = .{ .x = mouse.x + this.anchor.x, .y = mouse.y + this.anchor.y, .button = mouse.button, .kind = mouse.kind, }, }), }, else => try this.container.handle(event), } } fn render_container(container: Container(Event), cells: []Cell, container_size: Point) !void { const size = container.size; const origin = container.origin; const contents = try container.content(); defer container.allocator.free(contents); 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; } } for (container.elements.items) |child| try render_container(child, cells, size); } fn content(ctx: *anyopaque, 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.allocator.alloc(Cell, @as(usize, container_size.x) * @as(usize, container_size.y)); { const container_cells_const = try this.container.content(); defer this.container.allocator.free(container_cells_const); assert(container_cells_const.len == @as(usize, container_size.x) * @as(usize, container_size.y)); @memcpy(container_cells, container_cells_const); } for (this.container.elements.items) |child| try render_container(child, 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 &.{.dim}, // background (around scrollbar) }, .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 &.{.dim}, // background (around scrollbar) }, .cp = '🮄', // 5/8 block top (to make it look more equivalent to the vertical scrollbar) }; } } } this.container.allocator.free(container_cells); } }; } const std = @import("std"); const assert = std.debug.assert; 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 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, }; var box: Container(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(event.SystemEvent) = .init(box, .disabled); var container: Container(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(Container(event.SystemEvent), &container); try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.top.zon"), renderer.screen); // scroll down 15 times (exactly to the end) for (0..15) |_| try container.handle(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.vertical.bottom.zon"), renderer.screen); // further scrolling down will not change anything try container.handle(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); 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, }; var box: Container(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(event.SystemEvent) = .init(box, .enabled(.white)); var container: Container(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(Container(event.SystemEvent), &container); 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(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); 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(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); 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, }; var box: Container(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(event.SystemEvent) = .init(box, .disabled); var container: Container(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(Container(event.SystemEvent), &container); 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(.{ .mouse = .{ .button = .wheel_right, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.right.zon"), renderer.screen); // further scrolling right will not change anything try container.handle(.{ .mouse = .{ .button = .wheel_right, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); 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, }; var box: Container(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(event.SystemEvent) = .init(box, .enabled(.white)); var container: Container(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(Container(event.SystemEvent), &container); 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(.{ .mouse = .{ .button = .wheel_right, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); 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(.{ .mouse = .{ .button = .wheel_right, .kind = .press, .x = 5, .y = 5, }, }); try renderer.render(Container(event.SystemEvent), &container); try testing.expectEqualCells(.{}, renderer.size, @import("test/element/scrollable.horizontal.scrollbar.right.zon"), renderer.screen); }