diff --git a/build.zig b/build.zig index 39860d3..707ba40 100644 --- a/build.zig +++ b/build.zig @@ -13,6 +13,7 @@ pub fn build(b: *std.Build) void { progress, radio_button, scrollable, + selection, // layouts: vertical, horizontal, @@ -65,6 +66,7 @@ pub fn build(b: *std.Build) void { .progress => "examples/elements/progress.zig", .radio_button => "examples/elements/radio-button.zig", .scrollable => "examples/elements/scrollable.zig", + .selection => "examples/elements/selection.zig", // layouts: .vertical => "examples/layouts/vertical.zig", .horizontal => "examples/layouts/horizontal.zig", diff --git a/examples/elements/selection.zig b/examples/elements/selection.zig new file mode 100644 index 0000000..d1ef776 --- /dev/null +++ b/examples/elements/selection.zig @@ -0,0 +1,97 @@ +const QuitText = struct { + const text = "Press ctrl+c to quit."; + + pub fn element(this: *@This()) App.Element { + return .{ .ptr = this, .vtable = &.{ .content = content } }; + } + + fn content(ctx: *anyopaque, cells: []zterm.Cell, size: zterm.Point) !void { + _ = ctx; + assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); + + const row = 2; + const col = size.x / 2 -| (text.len / 2); + const anchor = (row * size.x) + col; + + for (text, 0..) |cp, idx| { + cells[anchor + idx].style.fg = .white; + cells[anchor + idx].style.bg = .black; + cells[anchor + idx].cp = cp; + + // NOTE do not write over the contents of this `Container`'s `Size` + if (anchor + idx == cells.len - 1) break; + } + } +}; + +pub fn main() !void { + errdefer |err| log.err("Application Error: {any}", .{err}); + + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer if (gpa.deinit() == .leak) log.err("memory leak", .{}); + + const allocator = gpa.allocator(); + + var app: App = .init; + var renderer = zterm.Renderer.Buffered.init(allocator); + defer renderer.deinit(); + + var selection: App.Selection(enum { + one, + two, + three, + }) = .init(.{ + .label = "Selection", + }); + var quit_text: QuitText = .{}; + + var container = try App.Container.init(allocator, .{ + .rectangle = .{ .fill = .grey }, + .layout = .{ .padding = .all(5) }, + }, quit_text.element()); + defer container.deinit(); + try container.append(try .init(allocator, .{}, selection.element())); + + try app.start(); + defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); + + // event loop + while (true) { + const event = app.nextEvent(); + log.debug("received event: {s}", .{@tagName(event)}); + + // pre event handling + switch (event) { + .key => |key| if (key.eql(.{ .cp = 'c', .mod = .{ .ctrl = true } })) app.quit(), + .err => |err| log.err("Received {s} with message: {s}", .{ @errorName(err.err), err.msg }), + else => {}, + } + + container.handle(event) catch |err| app.postEvent(.{ + .err = .{ + .err = err, + .msg = "Container Event handling failed", + }, + }); + + // post event handling + switch (event) { + .quit => break, + else => {}, + } + + container.resize(try renderer.resize()); + container.reposition(.{}); + try renderer.render(@TypeOf(container), &container); + try renderer.flush(); + } +} + +pub const panic = App.panic_handler; +const log = std.log.scoped(.default); + +const std = @import("std"); +const assert = std.debug.assert; +const zterm = @import("zterm"); +const Error = zterm.Error; +const App = zterm.App(union(enum) {}); diff --git a/src/app.zig b/src/app.zig index 51db452..3fcaa68 100644 --- a/src/app.zig +++ b/src/app.zig @@ -418,6 +418,7 @@ pub fn App(comptime E: type) type { pub const Progress = element.Progress(Event, Queue); pub const RadioButton = element.RadioButton(Event); pub const Scrollable = element.Scrollable(Event); + pub const Selection = element.Selection(Event); pub const Queue = queue.Queue(Event, 256); }; } diff --git a/src/element.zig b/src/element.zig index ed803d9..5d46508 100644 --- a/src/element.zig +++ b/src/element.zig @@ -65,7 +65,7 @@ pub fn Element(Event: type) type { } } }; -} +} // Element(Event: type) pub fn Alignment(Event: type) type { return struct { @@ -162,7 +162,7 @@ pub fn Alignment(Event: type) type { } } }; -} +} // Alignment(Event: type) pub fn Scrollable(Event: type) type { return struct { @@ -359,7 +359,7 @@ pub fn Scrollable(Event: type) type { this.container.allocator.free(container_cells); } }; -} +} // Scrollable(Event: type) pub fn Input(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 @@ -567,7 +567,7 @@ pub fn Input(Event: type, Queue: type) fn (meta.FieldEnum(Event)) type { } }; return input_struct.input_fn; -} +} // Input(Event: type, Queue: type) pub fn Button(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 @@ -645,7 +645,7 @@ pub fn Button(Event: type, Queue: type) fn (meta.FieldEnum(Event)) type { } }; return button_struct.button_fn; -} +} // Button(Event: type, Queue: type) pub fn RadioButton(Event: type) type { return struct { @@ -709,7 +709,149 @@ pub fn RadioButton(Event: type) type { } } }; -} +} // RadioButton(Event: type) + +pub fn Selection(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(Event) { + return .{ + .ptr = this, + .vtable = &.{ + .handle = handle, + .content = content, + }, + }; + } + + fn handle(ctx: *anyopaque, 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, 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(Enum: type) pub fn Progress(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 @@ -809,7 +951,7 @@ pub fn Progress(Event: type, Queue: type) fn (meta.FieldEnum(Event)) type { } }; return progress_struct.progress_fn; -} +} // Progress(Event: type, Queue: type) const std = @import("std"); const assert = std.debug.assert; @@ -819,6 +961,7 @@ 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; @@ -1386,6 +1529,8 @@ test "button" { }); } +// TODO add test cases for `RadioButton` and `Selection` + test "progress" { const allocator = std.testing.allocator; const event = @import("event.zig");