├── .gitignore ├── README.md ├── examples ├── flexrow.zig ├── flexcolumn.zig ├── text.zig ├── listview.zig ├── richtext.zig ├── playground.zig └── fuzzy.zig └── src ├── SizedBox.zig ├── Center.zig ├── Padding.zig ├── Spinner.zig ├── FlexRow.zig ├── FlexColumn.zig ├── Button.zig ├── RichText.zig ├── App.zig ├── main.zig ├── Text.zig ├── TextField.zig └── ListView.zig /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache/ 2 | zig-out/ 3 | *.log 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vtk 2 | 3 | Vaxis Toolkit (VTK) is an opinionated TUI library, built on 4 | [libvaxis](https://github.com/rockorager/libvaxis) 5 | -------------------------------------------------------------------------------- /examples/flexrow.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vtk = @import("vtk"); 3 | 4 | pub fn main() !void { 5 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 6 | defer { 7 | const deinit_status = gpa.deinit(); 8 | if (deinit_status == .leak) { 9 | std.log.err("memory leak", .{}); 10 | } 11 | } 12 | const allocator = gpa.allocator(); 13 | 14 | var app = try vtk.App.init(allocator); 15 | defer app.deinit(); 16 | 17 | const root = (vtk.FlexRow{ 18 | .children = &.{ 19 | .{ .widget = (vtk.Text{ 20 | .text = "abc\nsome other text", 21 | .text_align = .center, 22 | .style = .{ .reverse = true }, 23 | }).widget(), .flex = 2 }, 24 | 25 | .{ 26 | .widget = (vtk.Text{ 27 | .text = "def\nmore text", 28 | .text_align = .center, 29 | }).widget(), 30 | }, 31 | 32 | .{ .widget = (vtk.Text{ 33 | .text = "ghi\nHow many\nrows should we have?", 34 | .text_align = .center, 35 | .style = .{ .reverse = true }, 36 | }).widget(), .flex = 0 }, 37 | 38 | .{ .widget = (vtk.Text{ 39 | .text = "jkl", 40 | .text_align = .center, 41 | }).widget() }, 42 | }, 43 | }).widget(); 44 | 45 | try app.run(root, .{}); 46 | } 47 | -------------------------------------------------------------------------------- /examples/flexcolumn.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vtk = @import("vtk"); 3 | 4 | pub fn main() !void { 5 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 6 | defer { 7 | const deinit_status = gpa.deinit(); 8 | if (deinit_status == .leak) { 9 | std.log.err("memory leak", .{}); 10 | } 11 | } 12 | const allocator = gpa.allocator(); 13 | 14 | var app = try vtk.App.init(allocator); 15 | defer app.deinit(); 16 | 17 | const root = (vtk.FlexColumn{ 18 | .children = &.{ 19 | .{ .widget = (vtk.Text{ 20 | .text = "abc\nsome other text", 21 | .text_align = .center, 22 | .style = .{ .reverse = true }, 23 | }).widget(), .flex = 2 }, 24 | 25 | .{ 26 | .widget = (vtk.Text{ 27 | .text = "def\nmore text", 28 | .text_align = .right, 29 | }).widget(), 30 | }, 31 | 32 | .{ .widget = (vtk.Text{ 33 | .text = "ghi\nHow many\nrows should we have?", 34 | .text_align = .center, 35 | .style = .{ .reverse = true }, 36 | }).widget(), .flex = 0 }, 37 | 38 | .{ .widget = (vtk.Text{ 39 | .text = "jkl", 40 | .text_align = .center, 41 | }).widget() }, 42 | }, 43 | }).widget(); 44 | 45 | try app.run(root, .{}); 46 | } 47 | -------------------------------------------------------------------------------- /examples/text.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vtk = @import("vtk"); 3 | 4 | const lorem_ipsum = 5 | \\Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce finibus odio eu tellus dignissim finibus. Nullam tristique erat elit, commodo faucibus turpis consequat et. Ut vitae elit ex. Cras aliquam ante at nisi dapibus, placerat eleifend ligula interdum. Proin auctor tempus magna, sed luctus lorem scelerisque ac. Cras id diam leo. Curabitur ultrices tempus massa quis porta. Aenean in augue quis sapien mollis ullamcorper quis et dui. Vivamus ornare velit ut magna semper tincidunt. Nam id leo ipsum. Fusce non maximus lectus. Etiam tempus quam ut molestie eleifend. 6 | \\ 7 | \\Suspendisse a nisi vitae nunc vulputate rutrum eu nec nulla. Morbi id sapien eros. Vivamus sit amet venenatis sem. Aliquam velit eros, finibus eget dapibus non, semper et nisl. Nulla consectetur venenatis lacinia. Pellentesque vel turpis sapien. Praesent ipsum sem, eleifend sit amet ullamcorper et, sagittis elementum sem. 8 | \\ 9 | \\Cras consequat sit amet erat vel fringilla. Nullam eu elementum orci. Vestibulum ut iaculis dolor. Nulla sit amet congue augue, in laoreet libero. Nulla sodales erat eget sollicitudin ultricies. Etiam in urna quis neque imperdiet bibendum. Nulla ac tortor tristique, luctus lorem et, vehicula dolor. 10 | ; 11 | 12 | pub fn main() !void { 13 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 14 | defer { 15 | const deinit_status = gpa.deinit(); 16 | if (deinit_status == .leak) { 17 | std.log.err("memory leak", .{}); 18 | } 19 | } 20 | const allocator = gpa.allocator(); 21 | 22 | var app = try vtk.App.init(allocator); 23 | defer app.deinit(); 24 | 25 | const root = (vtk.Center{ 26 | .child = (vtk.Padding{ 27 | .child = (vtk.Text{ 28 | .text = lorem_ipsum, 29 | .text_align = .center, 30 | }).widget(), 31 | .padding = vtk.Padding.horizontal(24), 32 | }).widget(), 33 | }).widget(); 34 | 35 | try app.run(root, .{}); 36 | } 37 | -------------------------------------------------------------------------------- /examples/listview.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vtk = @import("vtk"); 3 | 4 | pub fn main() !void { 5 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 6 | defer { 7 | const deinit_status = gpa.deinit(); 8 | if (deinit_status == .leak) { 9 | std.log.err("memory leak", .{}); 10 | } 11 | } 12 | const allocator = gpa.allocator(); 13 | 14 | const rows = [_]vtk.Widget{ 15 | (vtk.Text{ .text = "Item 1\n ├─line 2\n ├─line 3\n ├─line 4\n └─line 5" }).widget(), 16 | (vtk.Text{ .text = "Item 2" }).widget(), 17 | (vtk.Text{ .text = "Item 3" }).widget(), 18 | (vtk.Text{ .text = "Item 4" }).widget(), 19 | (vtk.Text{ .text = "Item 5" }).widget(), 20 | (vtk.Text{ .text = "Item 6" }).widget(), 21 | (vtk.Text{ .text = "Item 7" }).widget(), 22 | (vtk.Text{ .text = "Item 8" }).widget(), 23 | (vtk.Text{ .text = "Item 9" }).widget(), 24 | (vtk.Text{ .text = "Item 10" }).widget(), 25 | (vtk.Text{ .text = "Item 11" }).widget(), 26 | (vtk.Text{ .text = "Item 12" }).widget(), 27 | (vtk.Text{ .text = "Item 13" }).widget(), 28 | (vtk.Text{ .text = "Item 14" }).widget(), 29 | (vtk.Text{ .text = "Item 15\n ├─line 2\n ├─line 3\n ├─line 4\n └─line 5" }).widget(), 30 | (vtk.Text{ .text = "Item 16" }).widget(), 31 | (vtk.Text{ .text = "Item 17" }).widget(), 32 | (vtk.Text{ .text = "Item 18" }).widget(), 33 | (vtk.Text{ .text = "Item 19" }).widget(), 34 | (vtk.Text{ .text = "Item 20" }).widget(), 35 | (vtk.Text{ .text = "Item 21" }).widget(), 36 | (vtk.Text{ .text = "Item 22" }).widget(), 37 | (vtk.Text{ .text = "Item 23" }).widget(), 38 | (vtk.Text{ .text = "Item 24" }).widget(), 39 | (vtk.Text{ .text = "Item 25" }).widget(), 40 | (vtk.Text{ .text = "Item 26" }).widget(), 41 | (vtk.Text{ .text = "Item 27" }).widget(), 42 | (vtk.Text{ .text = "Item 28" }).widget(), 43 | (vtk.Text{ .text = "Item 29\n ├─line 2\n ├─line 3\n ├─line 4\n └─line 5" }).widget(), 44 | }; 45 | 46 | var app = try vtk.App.init(allocator); 47 | defer app.deinit(); 48 | 49 | const root = (vtk.ListView{ 50 | .children = .{ .slice = &rows }, 51 | }).widget(); 52 | 53 | try app.run(root, .{}); 54 | } 55 | -------------------------------------------------------------------------------- /examples/richtext.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | const vtk = @import("vtk"); 4 | 5 | const lorem_ipsum = 6 | \\Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce finibus odio eu tellus dignissim finibus. Nullam tristique erat elit, commodo faucibus turpis consequat et. Ut vitae elit ex. Cras aliquam ante at nisi dapibus, placerat eleifend ligula interdum. Proin auctor tempus magna, sed luctus lorem scelerisque ac. Cras id diam leo. Curabitur ultrices tempus massa quis porta. Aenean in augue quis sapien mollis ullamcorper quis et dui. Vivamus ornare velit ut magna semper tincidunt. Nam id leo ipsum. Fusce non maximus lectus. Etiam tempus quam ut molestie eleifend. 7 | \\ 8 | \\Suspendisse a nisi vitae nunc vulputate rutrum eu nec nulla. Morbi id sapien eros. Vivamus sit amet venenatis sem. Aliquam velit eros, finibus eget dapibus non, semper et nisl. Nulla consectetur venenatis lacinia. Pellentesque vel turpis sapien. Praesent ipsum sem, eleifend sit amet ullamcorper et, sagittis elementum sem. 9 | \\ 10 | \\Cras consequat sit amet erat vel fringilla. Nullam eu elementum orci. Vestibulum ut iaculis dolor. Nulla sit amet congue augue, in laoreet libero. Nulla sodales erat eget sollicitudin ultricies. Etiam in urna quis neque imperdiet bibendum. Nulla ac tortor tristique, luctus lorem et, vehicula dolor. 11 | ; 12 | 13 | pub fn main() !void { 14 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 15 | defer { 16 | const deinit_status = gpa.deinit(); 17 | if (deinit_status == .leak) { 18 | std.log.err("memory leak", .{}); 19 | } 20 | } 21 | const allocator = gpa.allocator(); 22 | var arena = std.heap.ArenaAllocator.init(allocator); 23 | defer arena.deinit(); 24 | 25 | var color: u8 = 0; 26 | var list = std.ArrayList(vtk.RichText.TextSpan).init(arena.allocator()); 27 | for (lorem_ipsum) |b| { 28 | const char = try std.fmt.allocPrint(arena.allocator(), "{c}", .{b}); 29 | try list.append(.{ 30 | .text = char, 31 | .style = .{ .fg = .{ .index = color } }, 32 | }); 33 | color +%= 1; 34 | } 35 | 36 | var app = try vtk.App.init(allocator); 37 | defer app.deinit(); 38 | 39 | const root = (vtk.Center{ 40 | .child = (vtk.Padding{ 41 | .child = (vtk.RichText{ 42 | .text = list.items, 43 | .text_align = .center, 44 | }).widget(), 45 | .padding = vtk.Padding.horizontal(24), 46 | }).widget(), 47 | }).widget(); 48 | 49 | try app.run(root, .{}); 50 | } 51 | -------------------------------------------------------------------------------- /examples/playground.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vtk = @import("vtk"); 3 | 4 | const Model = struct { 5 | button: vtk.Button, 6 | text_field: vtk.TextField, 7 | count: usize, 8 | 9 | pub fn widget(self: *Model) vtk.Widget { 10 | return .{ 11 | .userdata = self, 12 | .eventHandler = Model.typeErasedEventHandler, 13 | .drawFn = Model.typeErasedDrawFn, 14 | }; 15 | } 16 | 17 | fn typeErasedEventHandler(_: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 18 | switch (event) { 19 | .key_press => |key| { 20 | if (key.matches('c', .{ .ctrl = true })) { 21 | ctx.quit = true; 22 | } 23 | }, 24 | else => {}, 25 | } 26 | } 27 | 28 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) std.mem.Allocator.Error!vtk.Surface { 29 | const self: *Model = @ptrCast(@alignCast(ptr)); 30 | self.button.label = try std.fmt.allocPrint( 31 | ctx.arena, 32 | "Hi, I'm a button.\nI've been clicked {d} times", 33 | .{self.count}, 34 | ); 35 | 36 | const flex: vtk.FlexRow = .{ 37 | .children = &.{ 38 | .{ 39 | .widget = (vtk.SizedBox{ 40 | .child = self.button.widget(), 41 | .size = .{ .width = 24, .height = 4 }, 42 | }).widget(), 43 | }, 44 | .{ 45 | .widget = (vtk.SizedBox{ 46 | .child = self.text_field.widget(), 47 | .size = .{ .width = 24, .height = 4 }, 48 | }).widget(), 49 | }, 50 | }, 51 | }; 52 | 53 | var surface = try flex.draw(ctx); 54 | surface.widget = self.widget(); 55 | 56 | return surface; 57 | } 58 | 59 | fn onClick(maybe_ptr: ?*anyopaque, ctx: *vtk.EventContext) anyerror!void { 60 | const ptr = maybe_ptr orelse return; 61 | const self: *Model = @ptrCast(@alignCast(ptr)); 62 | self.count +|= 1; 63 | return ctx.consumeAndRedraw(); 64 | } 65 | }; 66 | 67 | pub fn main() !void { 68 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 69 | defer _ = gpa.deinit(); 70 | 71 | const allocator = gpa.allocator(); 72 | 73 | var app = try vtk.App.init(allocator); 74 | defer app.deinit(); 75 | 76 | const model = try allocator.create(Model); 77 | defer allocator.destroy(model); 78 | model.* = .{ 79 | .count = 0, 80 | .button = .{ 81 | .label = "", 82 | .onClick = Model.onClick, 83 | .userdata = model, 84 | }, 85 | .text_field = vtk.TextField.init(allocator, &app.vx.unicode), 86 | }; 87 | defer model.text_field.deinit(); 88 | 89 | try app.run(model.widget(), .{}); 90 | } 91 | -------------------------------------------------------------------------------- /src/SizedBox.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const Allocator = std.mem.Allocator; 5 | 6 | const vtk = @import("main.zig"); 7 | 8 | const SizedBox = @This(); 9 | 10 | child: vtk.Widget, 11 | size: vtk.Size, 12 | 13 | pub fn widget(self: *const SizedBox) vtk.Widget { 14 | return .{ 15 | .userdata = @constCast(self), 16 | .eventHandler = typeErasedEventHandler, 17 | .drawFn = typeErasedDrawFn, 18 | }; 19 | } 20 | 21 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 22 | const self: *const SizedBox = @ptrCast(@alignCast(ptr)); 23 | return self.child.handleEvent(ctx, event); 24 | } 25 | 26 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 27 | const self: *const SizedBox = @ptrCast(@alignCast(ptr)); 28 | const max: vtk.MaxSize = .{ 29 | .width = if (ctx.max.width) |max_w| @min(max_w, self.size.width) else self.size.width, 30 | .height = if (ctx.max.height) |max_h| @min(max_h, self.size.height) else self.size.height, 31 | }; 32 | const min: vtk.Size = .{ 33 | .width = @max(ctx.min.width, max.width.?), 34 | .height = @max(ctx.min.height, max.height.?), 35 | }; 36 | return self.child.draw(ctx.withConstraints(min, max)); 37 | } 38 | 39 | test SizedBox { 40 | // Create a test widget that saves the constraints it was given 41 | const TestWidget = struct { 42 | min: vtk.Size, 43 | max: vtk.MaxSize, 44 | 45 | pub fn widget(self: *@This()) vtk.Widget { 46 | return .{ 47 | .userdata = self, 48 | .eventHandler = vtk.noopEventHandler, 49 | .drawFn = @This().typeErasedDrawFn, 50 | }; 51 | } 52 | 53 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) std.mem.Allocator.Error!vtk.Surface { 54 | const self: *@This() = @ptrCast(@alignCast(ptr)); 55 | self.min = ctx.min; 56 | self.max = ctx.max; 57 | return .{ 58 | .size = ctx.min, 59 | .widget = self.widget(), 60 | .buffer = &.{}, 61 | .children = &.{}, 62 | }; 63 | } 64 | }; 65 | 66 | // Boiler plate draw context 67 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 68 | defer arena.deinit(); 69 | const ucd = try vaxis.Unicode.init(arena.allocator()); 70 | vtk.DrawContext.init(&ucd, .unicode); 71 | 72 | var draw_ctx: vtk.DrawContext = .{ 73 | .arena = arena.allocator(), 74 | .min = .{}, 75 | .max = .{ .width = 16, .height = 16 }, 76 | }; 77 | 78 | var test_widget: TestWidget = .{ .min = .{}, .max = .{} }; 79 | 80 | // SizedBox tries to draw the child widget at the specified size. It will shrink to fit within 81 | // constraints 82 | const sized_box: SizedBox = .{ 83 | .child = test_widget.widget(), 84 | .size = .{ .width = 10, .height = 10 }, 85 | }; 86 | 87 | const box_widget = sized_box.widget(); 88 | _ = try box_widget.draw(draw_ctx); 89 | 90 | // The sized box is smaller than the constraints, so we should be the desired size 91 | try std.testing.expectEqual(sized_box.size, test_widget.min); 92 | try std.testing.expectEqual(sized_box.size, test_widget.max.size()); 93 | 94 | draw_ctx.max.height = 8; 95 | _ = try box_widget.draw(draw_ctx); 96 | // The sized box is smaller than the constraints, so we should be that size 97 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 10, .height = 8 }), test_widget.min); 98 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 10, .height = 8 }), test_widget.max.size()); 99 | 100 | draw_ctx.max.width = 8; 101 | _ = try box_widget.draw(draw_ctx); 102 | // The sized box is smaller than the constraints, so we should be that size 103 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 8, .height = 8 }), test_widget.min); 104 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 8, .height = 8 }), test_widget.max.size()); 105 | } 106 | 107 | test "refAllDecls" { 108 | std.testing.refAllDecls(@This()); 109 | } 110 | -------------------------------------------------------------------------------- /src/Center.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const Allocator = std.mem.Allocator; 5 | 6 | const vtk = @import("main.zig"); 7 | 8 | const Center = @This(); 9 | 10 | child: vtk.Widget, 11 | 12 | pub fn widget(self: *const Center) vtk.Widget { 13 | return .{ 14 | .userdata = @constCast(self), 15 | .eventHandler = typeErasedEventHandler, 16 | .drawFn = typeErasedDrawFn, 17 | }; 18 | } 19 | 20 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 21 | const self: *const Center = @ptrCast(@alignCast(ptr)); 22 | return self.child.handleEvent(ctx, event); 23 | } 24 | 25 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 26 | const self: *const Center = @ptrCast(@alignCast(ptr)); 27 | return self.draw(ctx); 28 | } 29 | 30 | /// Cannot have unbounded constraints 31 | pub fn draw(self: *const Center, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 32 | const child_ctx = ctx.withConstraints(.{ .width = 0, .height = 0 }, ctx.max); 33 | const max_size = ctx.max.size(); 34 | const child = try self.child.draw(child_ctx); 35 | 36 | const x = (max_size.width - child.size.width) / 2; 37 | const y = (max_size.height - child.size.height) / 2; 38 | 39 | const children = try ctx.arena.alloc(vtk.SubSurface, 1); 40 | children[0] = .{ 41 | .origin = .{ .col = x, .row = y }, 42 | .z_index = 0, 43 | .surface = child, 44 | }; 45 | 46 | return .{ 47 | .size = max_size, 48 | .widget = self.widget(), 49 | .buffer = &.{}, 50 | .children = children, 51 | }; 52 | } 53 | 54 | test Center { 55 | const Text = @import("Text.zig"); 56 | // Will be height=1, width=3 57 | const text: Text = .{ .text = "abc" }; 58 | 59 | const center: Center = .{ .child = text.widget() }; 60 | 61 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 62 | defer arena.deinit(); 63 | const ucd = try vaxis.Unicode.init(arena.allocator()); 64 | vtk.DrawContext.init(&ucd, .unicode); 65 | 66 | { 67 | // Center expands to the max size. It must therefore have non-null max width and max height. 68 | // These values are asserted in draw 69 | const ctx: vtk.DrawContext = .{ 70 | .arena = arena.allocator(), 71 | .min = .{}, 72 | .max = .{ .width = 10, .height = 10 }, 73 | }; 74 | 75 | const surface = try center.draw(ctx); 76 | // Center does not produce any drawable cells 77 | try std.testing.expectEqual(0, surface.buffer.len); 78 | // Center has 1 child 79 | try std.testing.expectEqual(1, surface.children.len); 80 | // Center is the max size 81 | try std.testing.expectEqual(surface.size, ctx.max.size()); 82 | const child = surface.children[0]; 83 | // The child is 1x3 84 | try std.testing.expectEqual(3, child.surface.size.width); 85 | try std.testing.expectEqual(1, child.surface.size.height); 86 | // A centered 1x3 in 10x10 should be at origin 3, 4. The bias is toward the top left corner 87 | try std.testing.expectEqual(4, child.origin.row); 88 | try std.testing.expectEqual(3, child.origin.col); 89 | } 90 | { 91 | // Center expands to the max size. It must therefore have non-null max width and max height. 92 | // These values are asserted in draw 93 | const ctx: vtk.DrawContext = .{ 94 | .arena = arena.allocator(), 95 | .min = .{}, 96 | .max = .{ .width = 5, .height = 3 }, 97 | }; 98 | 99 | const surface = try center.draw(ctx); 100 | // Center does not produce any drawable cells 101 | try std.testing.expectEqual(0, surface.buffer.len); 102 | // Center has 1 child 103 | try std.testing.expectEqual(1, surface.children.len); 104 | // Center is the max size 105 | try std.testing.expectEqual(surface.size, ctx.max.size()); 106 | const child = surface.children[0]; 107 | // The child is 1x3 108 | try std.testing.expectEqual(3, child.surface.size.width); 109 | try std.testing.expectEqual(1, child.surface.size.height); 110 | // A centered 1x3 in 3x5 should be at origin 1, 1. This is a perfectly centered child 111 | try std.testing.expectEqual(1, child.origin.row); 112 | try std.testing.expectEqual(1, child.origin.col); 113 | } 114 | } 115 | 116 | test "refAllDecls" { 117 | std.testing.refAllDecls(@This()); 118 | } 119 | -------------------------------------------------------------------------------- /src/Padding.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const Allocator = std.mem.Allocator; 5 | 6 | const vtk = @import("main.zig"); 7 | 8 | const Padding = @This(); 9 | const PadValues = struct { 10 | left: u16 = 0, 11 | right: u16 = 0, 12 | top: u16 = 0, 13 | bottom: u16 = 0, 14 | }; 15 | 16 | child: vtk.Widget, 17 | padding: PadValues = .{}, 18 | 19 | /// Vertical padding will be divided by 2 to approximate equal padding 20 | pub fn all(padding: u16) PadValues { 21 | return .{ 22 | .left = padding, 23 | .right = padding, 24 | .top = padding / 2, 25 | .bottom = padding / 2, 26 | }; 27 | } 28 | 29 | pub fn horizontal(padding: u16) PadValues { 30 | return .{ 31 | .left = padding, 32 | .right = padding, 33 | }; 34 | } 35 | 36 | pub fn vertical(padding: u16) PadValues { 37 | return .{ 38 | .top = padding, 39 | .bottom = padding, 40 | }; 41 | } 42 | 43 | pub fn widget(self: *const Padding) vtk.Widget { 44 | return .{ 45 | .userdata = @constCast(self), 46 | .eventHandler = typeErasedEventHandler, 47 | .drawFn = typeErasedDrawFn, 48 | }; 49 | } 50 | 51 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 52 | const self: *const Padding = @ptrCast(@alignCast(ptr)); 53 | return self.child.handleEvent(ctx, event); 54 | } 55 | 56 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 57 | const self: *const Padding = @ptrCast(@alignCast(ptr)); 58 | return self.draw(ctx); 59 | } 60 | 61 | pub fn draw(self: *const Padding, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 62 | const pad = self.padding; 63 | if (pad.left > 0 or pad.right > 0) 64 | std.debug.assert(ctx.max.width != null); 65 | if (pad.top > 0 or pad.bottom > 0) 66 | std.debug.assert(ctx.max.height != null); 67 | const inner_min: vtk.Size = .{ 68 | .width = ctx.min.width -| (pad.right + pad.left), 69 | .height = ctx.min.height -| (pad.top + pad.bottom), 70 | }; 71 | 72 | const max_width: ?u16 = if (ctx.max.width) |max| 73 | max -| (pad.right + pad.left) 74 | else 75 | null; 76 | const max_height: ?u16 = if (ctx.max.height) |max| 77 | max -| (pad.top + pad.bottom) 78 | else 79 | null; 80 | 81 | const inner_max: vtk.MaxSize = .{ 82 | .width = max_width, 83 | .height = max_height, 84 | }; 85 | 86 | const child_surface = try self.child.draw(ctx.withConstraints(inner_min, inner_max)); 87 | 88 | const children = try ctx.arena.alloc(vtk.SubSurface, 1); 89 | children[0] = .{ 90 | .surface = child_surface, 91 | .z_index = 0, 92 | .origin = .{ .row = pad.top, .col = pad.left }, 93 | }; 94 | 95 | const size = .{ 96 | .width = child_surface.size.width + (pad.right + pad.left), 97 | .height = child_surface.size.height + (pad.top + pad.bottom), 98 | }; 99 | 100 | // Create the padding surface 101 | return .{ 102 | .size = size, 103 | .widget = self.widget(), 104 | .buffer = &.{}, 105 | .children = children, 106 | }; 107 | } 108 | 109 | test Padding { 110 | const Text = @import("Text.zig"); 111 | // Will be height=1, width=3 112 | const text: Text = .{ .text = "abc" }; 113 | 114 | const padding: Padding = .{ 115 | .child = text.widget(), 116 | .padding = horizontal(1), 117 | }; 118 | 119 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 120 | defer arena.deinit(); 121 | const ucd = try vaxis.Unicode.init(arena.allocator()); 122 | vtk.DrawContext.init(&ucd, .unicode); 123 | 124 | // Center expands to the max size. It must therefore have non-null max width and max height. 125 | // These values are asserted in draw 126 | const ctx: vtk.DrawContext = .{ 127 | .arena = arena.allocator(), 128 | .min = .{}, 129 | .max = .{ .width = 10, .height = 10 }, 130 | }; 131 | 132 | const pad_widget = padding.widget(); 133 | 134 | const surface = try pad_widget.draw(ctx); 135 | // Padding does not produce any drawable cells 136 | try std.testing.expectEqual(0, surface.buffer.len); 137 | // Padding has 1 child 138 | try std.testing.expectEqual(1, surface.children.len); 139 | const child = surface.children[0]; 140 | // Padding is the child size + padding 141 | try std.testing.expectEqual(child.surface.size.width + 2, surface.size.width); 142 | try std.testing.expectEqual(0, child.origin.row); 143 | try std.testing.expectEqual(1, child.origin.col); 144 | } 145 | 146 | test "refAllDecls" { 147 | std.testing.refAllDecls(@This()); 148 | } 149 | -------------------------------------------------------------------------------- /src/Spinner.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const vtk = @import("main.zig"); 5 | 6 | const Allocator = std.mem.Allocator; 7 | 8 | const Spinner = @This(); 9 | 10 | const frames: []const []const u8 = &.{ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; 11 | const time_lapse: u32 = std.time.ms_per_s / 12; // 12 fps 12 | 13 | count: std.atomic.Value(u16) = .{ .raw = 0 }, 14 | style: vaxis.Style = .{}, 15 | /// The frame index 16 | frame: u4 = 0, 17 | 18 | /// Start, or add one, to the spinner counter. Thread safe. 19 | pub fn start(self: *Spinner) ?vtk.Command { 20 | const count = self.count.fetchAdd(1, .monotonic); 21 | if (count == 0) { 22 | return vtk.Tick.in(time_lapse, self.widget()); 23 | } 24 | return null; 25 | } 26 | 27 | /// Reduce one from the spinner counter. The spinner will stop when it reaches 0. Thread safe 28 | pub fn stop(self: *Spinner) void { 29 | self.count.store(self.count.load(.unordered) -| 1, .unordered); 30 | } 31 | 32 | pub fn widget(self: *Spinner) vtk.Widget { 33 | return .{ 34 | .userdata = self, 35 | .eventHandler = typeErasedEventHandler, 36 | .drawFn = typeErasedDrawFn, 37 | }; 38 | } 39 | 40 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 41 | const self: *Spinner = @ptrCast(@alignCast(ptr)); 42 | return self.handleEvent(ctx, event); 43 | } 44 | 45 | pub fn handleEvent(self: *Spinner, ctx: *vtk.EventContext, event: vtk.Event) Allocator.Error!void { 46 | switch (event) { 47 | .tick => { 48 | const count = self.count.load(.unordered); 49 | 50 | if (count == 0) return; 51 | // Update frame 52 | self.frame += 1; 53 | if (self.frame >= frames.len) self.frame = 0; 54 | 55 | // Update rearm 56 | try ctx.tick(time_lapse, self.widget()); 57 | }, 58 | else => {}, 59 | } 60 | } 61 | 62 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 63 | const self: *Spinner = @ptrCast(@alignCast(ptr)); 64 | return self.draw(ctx); 65 | } 66 | 67 | pub fn draw(self: *Spinner, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 68 | const size: vtk.Size = .{ 69 | .width = @max(1, ctx.min.width), 70 | .height = @max(1, ctx.min.height), 71 | }; 72 | 73 | const surface = try vtk.Surface.init(ctx.arena, self.widget(), size); 74 | @memset(surface.buffer, .{ .style = self.style }); 75 | 76 | if (self.count.load(.unordered) == 0) return surface; 77 | 78 | surface.writeCell(0, 0, .{ 79 | .char = .{ 80 | .grapheme = frames[self.frame], 81 | .width = 1, 82 | }, 83 | .style = self.style, 84 | }); 85 | return surface; 86 | } 87 | 88 | test Spinner { 89 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 90 | defer arena.deinit(); 91 | // Create a spinner 92 | var spinner: Spinner = .{}; 93 | // Get our widget interface 94 | const spinner_widget = spinner.widget(); 95 | 96 | // Start the spinner. This (maybe) returns a Tick command to schedule the next frame. If the 97 | // spinner is already running, no command is returned. Calling start is thread safe. The 98 | // returned command can be added to an EventContext to schedule the frame 99 | const maybe_cmd = spinner.start(); 100 | try std.testing.expect(maybe_cmd != null); 101 | try std.testing.expect(maybe_cmd.? == .tick); 102 | try std.testing.expectEqual(1, spinner.count.load(.unordered)); 103 | 104 | // If we call start again, we won't get another command but our counter will go up 105 | const maybe_cmd2 = spinner.start(); 106 | try std.testing.expect(maybe_cmd2 == null); 107 | try std.testing.expectEqual(2, spinner.count.load(.unordered)); 108 | 109 | // We are about to deliver the tick to the widget. We need an EventContext (the engine will 110 | // provide this) 111 | var ctx: vtk.EventContext = .{ .cmds = vtk.CommandList.init(arena.allocator()) }; 112 | 113 | // The event loop handles the tick event and calls us back with a .tick event. If we should keep 114 | // running, we will add a new tick event 115 | try spinner_widget.handleEvent(&ctx, .tick); 116 | 117 | // Receiving a .tick advances the frame 118 | try std.testing.expectEqual(1, spinner.frame); 119 | 120 | // Simulate a draw 121 | const surface = try spinner_widget.draw(.{ .arena = arena.allocator(), .min = .{}, .max = .{} }); 122 | 123 | // Spinner will try to be 1x1 124 | try std.testing.expectEqual(1, surface.size.width); 125 | try std.testing.expectEqual(1, surface.size.height); 126 | 127 | // Stopping the spinner decrements our counter 128 | spinner.stop(); 129 | try std.testing.expectEqual(1, spinner.count.load(.unordered)); 130 | spinner.stop(); 131 | try std.testing.expectEqual(0, spinner.count.load(.unordered)); 132 | } 133 | 134 | test "refAllDecls" { 135 | std.testing.refAllDecls(@This()); 136 | } 137 | -------------------------------------------------------------------------------- /src/FlexRow.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const vtk = @import("main.zig"); 5 | 6 | const Allocator = std.mem.Allocator; 7 | 8 | const FlexRow = @This(); 9 | 10 | children: []const vtk.FlexItem, 11 | 12 | pub fn widget(self: *const FlexRow) vtk.Widget { 13 | return .{ 14 | .userdata = @constCast(self), 15 | .eventHandler = vtk.noopEventHandler, 16 | .drawFn = typeErasedDrawFn, 17 | }; 18 | } 19 | 20 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 21 | const self: *const FlexRow = @ptrCast(@alignCast(ptr)); 22 | return self.draw(ctx); 23 | } 24 | 25 | pub fn draw(self: *const FlexRow, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 26 | std.debug.assert(ctx.max.height != null); 27 | std.debug.assert(ctx.max.width != null); 28 | if (self.children.len == 0) return vtk.Surface.init(ctx.arena, self.widget(), ctx.min); 29 | 30 | // Store the inherent size of each widget 31 | const size_list = try ctx.arena.alloc(u16, self.children.len); 32 | 33 | var layout_arena = std.heap.ArenaAllocator.init(ctx.arena); 34 | 35 | const layout_ctx: vtk.DrawContext = .{ 36 | .min = .{ .width = 0, .height = 0 }, 37 | .max = .{ .width = null, .height = ctx.max.height }, 38 | .arena = layout_arena.allocator(), 39 | }; 40 | 41 | var first_pass_width: u16 = 0; 42 | var total_flex: u16 = 0; 43 | for (self.children, 0..) |child, i| { 44 | const surf = try child.widget.draw(layout_ctx); 45 | first_pass_width += surf.size.width; 46 | total_flex += child.flex; 47 | size_list[i] = surf.size.width; 48 | } 49 | 50 | // We are done with the layout arena 51 | layout_arena.deinit(); 52 | 53 | // make our children list 54 | var children = std.ArrayList(vtk.SubSurface).init(ctx.arena); 55 | 56 | // Draw again, but with distributed widths 57 | var second_pass_width: u16 = 0; 58 | var max_height: u16 = 0; 59 | const remaining_space = ctx.max.width.? - first_pass_width; 60 | for (self.children, 1..) |child, i| { 61 | const inherent_width = size_list[i - 1]; 62 | const child_width = if (child.flex == 0) 63 | inherent_width 64 | else if (i == self.children.len) 65 | // If we are the last one, we just get the remainder 66 | ctx.max.width.? - second_pass_width 67 | else 68 | inherent_width + (remaining_space * child.flex) / total_flex; 69 | 70 | // Create a context for the child 71 | const child_ctx = ctx.withConstraints( 72 | .{ .width = child_width, .height = 0 }, 73 | .{ .width = child_width, .height = ctx.max.height.? }, 74 | ); 75 | const surf = try child.widget.draw(child_ctx); 76 | 77 | try children.append(.{ 78 | .origin = .{ .col = second_pass_width, .row = 0 }, 79 | .surface = surf, 80 | .z_index = 0, 81 | }); 82 | max_height = @max(max_height, surf.size.height); 83 | second_pass_width += surf.size.width; 84 | } 85 | const size = .{ .width = second_pass_width, .height = max_height }; 86 | return .{ 87 | .size = size, 88 | .widget = self.widget(), 89 | .buffer = &.{}, 90 | .children = children.items, 91 | }; 92 | } 93 | 94 | test FlexRow { 95 | // Create child widgets 96 | const Text = @import("Text.zig"); 97 | // Will be height=1, width=3 98 | const abc: Text = .{ .text = "abc" }; 99 | const def: Text = .{ .text = "def" }; 100 | const ghi: Text = .{ .text = "ghi" }; 101 | const jklmno: Text = .{ .text = "jkl\nmno" }; 102 | 103 | // Create the flex row 104 | const flex_row: FlexRow = .{ 105 | .children = &.{ 106 | .{ .widget = abc.widget(), .flex = 0 }, // flex=0 means we are our inherent size 107 | .{ .widget = def.widget(), .flex = 1 }, 108 | .{ .widget = ghi.widget(), .flex = 1 }, 109 | .{ .widget = jklmno.widget(), .flex = 1 }, 110 | }, 111 | }; 112 | 113 | // Boiler plate draw context 114 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 115 | defer arena.deinit(); 116 | const ucd = try vaxis.Unicode.init(arena.allocator()); 117 | vtk.DrawContext.init(&ucd, .unicode); 118 | 119 | const flex_widget = flex_row.widget(); 120 | const ctx: vtk.DrawContext = .{ 121 | .arena = arena.allocator(), 122 | .min = .{}, 123 | .max = .{ .width = 16, .height = 16 }, 124 | }; 125 | 126 | const surface = try flex_widget.draw(ctx); 127 | // FlexRow expands to max width and tallest child 128 | try std.testing.expectEqual(16, surface.size.width); 129 | try std.testing.expectEqual(2, surface.size.height); 130 | // We have four children 131 | try std.testing.expectEqual(4, surface.children.len); 132 | 133 | // We will track the column we are on to confirm the origins 134 | var col: u16 = 0; 135 | // First child has flex=0, it should be it's inherent width 136 | try std.testing.expectEqual(3, surface.children[0].surface.size.width); 137 | try std.testing.expectEqual(col, surface.children[0].origin.col); 138 | // Add the child height each time 139 | col += surface.children[0].surface.size.width; 140 | // Let's do some math 141 | // - We have 4 children to fit into 16 cols. All children will be 3 wide for a total width of 12 142 | // - The first child is 3 cols and no flex. The rest of the width gets distributed evenly among 143 | // the remaining 3 children. The remainder width is 16 - 12 = 4, so each child should get 4 / 144 | // 3 = 1 extra cols, and the last will receive the remainder 145 | try std.testing.expectEqual(1 + 3, surface.children[1].surface.size.width); 146 | try std.testing.expectEqual(col, surface.children[1].origin.col); 147 | col += surface.children[1].surface.size.width; 148 | 149 | try std.testing.expectEqual(1 + 3, surface.children[2].surface.size.width); 150 | try std.testing.expectEqual(col, surface.children[2].origin.col); 151 | col += surface.children[2].surface.size.width; 152 | 153 | try std.testing.expectEqual(1 + 3 + 1, surface.children[3].surface.size.width); 154 | try std.testing.expectEqual(col, surface.children[3].origin.col); 155 | } 156 | 157 | test "refAllDecls" { 158 | std.testing.refAllDecls(@This()); 159 | } 160 | -------------------------------------------------------------------------------- /src/FlexColumn.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const Allocator = std.mem.Allocator; 5 | 6 | const vtk = @import("main.zig"); 7 | 8 | const FlexColumn = @This(); 9 | 10 | children: []const vtk.FlexItem, 11 | 12 | pub fn widget(self: *const FlexColumn) vtk.Widget { 13 | return .{ 14 | .userdata = @constCast(self), 15 | .eventHandler = vtk.noopEventHandler, 16 | .drawFn = typeErasedDrawFn, 17 | }; 18 | } 19 | 20 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 21 | const self: *const FlexColumn = @ptrCast(@alignCast(ptr)); 22 | return self.draw(ctx); 23 | } 24 | 25 | pub fn draw(self: *const FlexColumn, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 26 | std.debug.assert(ctx.max.height != null); 27 | std.debug.assert(ctx.max.width != null); 28 | if (self.children.len == 0) return vtk.Surface.init(ctx.arena, self.widget(), ctx.min); 29 | 30 | // Store the inherent size of each widget 31 | const size_list = try ctx.arena.alloc(u16, self.children.len); 32 | 33 | var layout_arena = std.heap.ArenaAllocator.init(ctx.arena); 34 | 35 | const layout_ctx: vtk.DrawContext = .{ 36 | .min = .{ .width = 0, .height = 0 }, 37 | .max = .{ .width = ctx.max.width, .height = null }, 38 | .arena = layout_arena.allocator(), 39 | }; 40 | 41 | // Store the inherent size of each widget 42 | var first_pass_height: u16 = 0; 43 | var total_flex: u16 = 0; 44 | for (self.children, 0..) |child, i| { 45 | const surf = try child.widget.draw(layout_ctx); 46 | first_pass_height += surf.size.height; 47 | total_flex += child.flex; 48 | size_list[i] = surf.size.height; 49 | } 50 | 51 | // We are done with the layout arena 52 | layout_arena.deinit(); 53 | 54 | // make our children list 55 | var children = std.ArrayList(vtk.SubSurface).init(ctx.arena); 56 | 57 | // Draw again, but with distributed heights 58 | var second_pass_height: u16 = 0; 59 | var max_width: u16 = 0; 60 | const remaining_space = ctx.max.height.? - first_pass_height; 61 | for (self.children, 1..) |child, i| { 62 | const inherent_height = size_list[i - 1]; 63 | const child_height = if (child.flex == 0) 64 | inherent_height 65 | else if (i == self.children.len) 66 | // If we are the last one, we just get the remainder 67 | ctx.max.height.? - second_pass_height 68 | else 69 | inherent_height + (remaining_space * child.flex) / total_flex; 70 | 71 | // Create a context for the child 72 | const child_ctx = ctx.withConstraints( 73 | .{ .width = 0, .height = child_height }, 74 | .{ .width = ctx.max.width.?, .height = child_height }, 75 | ); 76 | const surf = try child.widget.draw(child_ctx); 77 | 78 | try children.append(.{ 79 | .origin = .{ .col = 0, .row = second_pass_height }, 80 | .surface = surf, 81 | .z_index = 0, 82 | }); 83 | max_width = @max(max_width, surf.size.width); 84 | second_pass_height += surf.size.height; 85 | } 86 | 87 | const size = .{ .width = max_width, .height = second_pass_height }; 88 | return .{ 89 | .size = size, 90 | .widget = self.widget(), 91 | .buffer = &.{}, 92 | .children = children.items, 93 | }; 94 | } 95 | 96 | test FlexColumn { 97 | // Create child widgets 98 | const Text = @import("Text.zig"); 99 | // Will be height=1, width=3 100 | const abc: Text = .{ .text = "abc" }; 101 | const def: Text = .{ .text = "def" }; 102 | const ghi: Text = .{ .text = "ghi" }; 103 | const jklmno: Text = .{ .text = "jkl\nmno" }; 104 | 105 | // Create the flex column 106 | const flex_column: FlexColumn = .{ 107 | .children = &.{ 108 | .{ .widget = abc.widget(), .flex = 0 }, // flex=0 means we are our inherent size 109 | .{ .widget = def.widget(), .flex = 1 }, 110 | .{ .widget = ghi.widget(), .flex = 1 }, 111 | .{ .widget = jklmno.widget(), .flex = 1 }, 112 | }, 113 | }; 114 | 115 | // Boiler plate draw context 116 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 117 | defer arena.deinit(); 118 | const ucd = try vaxis.Unicode.init(arena.allocator()); 119 | vtk.DrawContext.init(&ucd, .unicode); 120 | 121 | const flex_widget = flex_column.widget(); 122 | const ctx: vtk.DrawContext = .{ 123 | .arena = arena.allocator(), 124 | .min = .{}, 125 | .max = .{ .width = 16, .height = 16 }, 126 | }; 127 | 128 | const surface = try flex_widget.draw(ctx); 129 | // FlexColumn expands to max height and widest child 130 | try std.testing.expectEqual(16, surface.size.height); 131 | try std.testing.expectEqual(3, surface.size.width); 132 | // We have four children 133 | try std.testing.expectEqual(4, surface.children.len); 134 | 135 | // We will track the row we are on to confirm the origins 136 | var row: u16 = 0; 137 | // First child has flex=0, it should be it's inherent height 138 | try std.testing.expectEqual(1, surface.children[0].surface.size.height); 139 | try std.testing.expectEqual(row, surface.children[0].origin.row); 140 | // Add the child height each time 141 | row += surface.children[0].surface.size.height; 142 | // Let's do some math 143 | // - We have 4 children to fit into 16 rows. 3 children will be 1 row tall, one will be 2 rows 144 | // tall for a total height of 5 rows. 145 | // - The first child is 1 row and no flex. The rest of the height gets distributed evenly among 146 | // the remaining 3 children. The remainder height is 16 - 5 = 11, so each child should get 11 / 147 | // 3 = 3 extra rows, and the last will receive the remainder 148 | try std.testing.expectEqual(1 + 3, surface.children[1].surface.size.height); 149 | try std.testing.expectEqual(row, surface.children[1].origin.row); 150 | row += surface.children[1].surface.size.height; 151 | 152 | try std.testing.expectEqual(1 + 3, surface.children[2].surface.size.height); 153 | try std.testing.expectEqual(row, surface.children[2].origin.row); 154 | row += surface.children[2].surface.size.height; 155 | 156 | try std.testing.expectEqual(2 + 3 + 2, surface.children[3].surface.size.height); 157 | try std.testing.expectEqual(row, surface.children[3].origin.row); 158 | } 159 | 160 | test "refAllDecls" { 161 | std.testing.refAllDecls(@This()); 162 | } 163 | -------------------------------------------------------------------------------- /src/Button.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const vtk = @import("main.zig"); 5 | 6 | const Allocator = std.mem.Allocator; 7 | 8 | const Center = @import("Center.zig"); 9 | const Text = @import("Text.zig"); 10 | 11 | const Button = @This(); 12 | 13 | // User supplied values 14 | label: []const u8, 15 | onClick: *const fn (?*anyopaque, ctx: *vtk.EventContext) anyerror!void, 16 | userdata: ?*anyopaque = null, 17 | 18 | // Styles 19 | style: struct { 20 | default: vaxis.Style = .{ .reverse = true }, 21 | mouse_down: vaxis.Style = .{ .fg = .{ .index = 4 }, .reverse = true }, 22 | hover: vaxis.Style = .{ .fg = .{ .index = 3 }, .reverse = true }, 23 | focus: vaxis.Style = .{ .fg = .{ .index = 5 }, .reverse = true }, 24 | } = .{}, 25 | 26 | // State 27 | mouse_down: bool = false, 28 | has_mouse: bool = false, 29 | focused: bool = false, 30 | 31 | pub fn widget(self: *Button) vtk.Widget { 32 | return .{ 33 | .userdata = self, 34 | .eventHandler = typeErasedEventHandler, 35 | .drawFn = typeErasedDrawFn, 36 | }; 37 | } 38 | 39 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 40 | const self: *Button = @ptrCast(@alignCast(ptr)); 41 | return self.handleEvent(ctx, event); 42 | } 43 | 44 | pub fn handleEvent(self: *Button, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 45 | switch (event) { 46 | .key_press => |key| { 47 | if (key.matches(vaxis.Key.enter, .{})) { 48 | return self.doClick(ctx); 49 | } 50 | }, 51 | .mouse => |mouse| { 52 | if (self.mouse_down and mouse.type == .release) { 53 | self.mouse_down = false; 54 | return self.doClick(ctx); 55 | } 56 | if (mouse.type == .press and mouse.button == .left) { 57 | self.mouse_down = true; 58 | return ctx.consumeAndRedraw(); 59 | } 60 | if (!self.has_mouse) { 61 | self.has_mouse = true; 62 | 63 | // implicit redraw 64 | try ctx.setMouseShape(.pointer); 65 | return ctx.consumeAndRedraw(); 66 | } 67 | return ctx.consumeEvent(); 68 | }, 69 | .mouse_leave => { 70 | self.has_mouse = false; 71 | self.mouse_down = false; 72 | // implicit redraw 73 | try ctx.setMouseShape(.default); 74 | }, 75 | .focus_in => { 76 | self.focused = true; 77 | ctx.redraw = true; 78 | }, 79 | .focus_out => { 80 | self.focused = false; 81 | ctx.redraw = true; 82 | }, 83 | else => {}, 84 | } 85 | } 86 | 87 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 88 | const self: *Button = @ptrCast(@alignCast(ptr)); 89 | return self.draw(ctx); 90 | } 91 | 92 | pub fn draw(self: *Button, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 93 | const style: vaxis.Style = if (self.mouse_down) 94 | self.style.mouse_down 95 | else if (self.has_mouse) 96 | self.style.hover 97 | else if (self.focused) 98 | self.style.focus 99 | else 100 | self.style.default; 101 | 102 | const text: Text = .{ 103 | .style = style, 104 | .text = self.label, 105 | .text_align = .center, 106 | }; 107 | 108 | const center: Center = .{ .child = text.widget() }; 109 | const surf = try center.draw(ctx); 110 | 111 | var button_surf = try vtk.Surface.initWithChildren(ctx.arena, self.widget(), surf.size, surf.children); 112 | @memset(button_surf.buffer, .{ .style = style }); 113 | button_surf.handles_mouse = true; 114 | button_surf.focusable = true; 115 | return button_surf; 116 | } 117 | 118 | fn doClick(self: *Button, ctx: *vtk.EventContext) anyerror!void { 119 | try self.onClick(self.userdata, ctx); 120 | ctx.consume_event = true; 121 | } 122 | 123 | test Button { 124 | // Create some object which reacts to a button press 125 | const Foo = struct { 126 | count: u8, 127 | 128 | fn onClick(ptr: ?*anyopaque, ctx: *vtk.EventContext) anyerror!void { 129 | const foo: *@This() = @ptrCast(@alignCast(ptr)); 130 | foo.count +|= 1; 131 | ctx.consumeAndRedraw(); 132 | } 133 | }; 134 | var foo: Foo = .{ .count = 0 }; 135 | 136 | var button: Button = .{ 137 | .label = "Test Button", 138 | .onClick = Foo.onClick, 139 | .userdata = &foo, 140 | }; 141 | 142 | // Event handlers need a context 143 | var ctx: vtk.EventContext = .{ 144 | .cmds = std.ArrayList(vtk.Command).init(std.testing.allocator), 145 | }; 146 | defer ctx.cmds.deinit(); 147 | 148 | // Get the widget interface 149 | const b_widget = button.widget(); 150 | 151 | // Create a synthetic mouse event 152 | var mouse_event: vaxis.Mouse = .{ 153 | .col = 0, 154 | .row = 0, 155 | .mods = .{}, 156 | .button = .left, 157 | .type = .press, 158 | }; 159 | // Send the button a mouse press event 160 | try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 161 | 162 | // A press alone doesn't trigger onClick 163 | try std.testing.expectEqual(0, foo.count); 164 | 165 | // Send the button a mouse release event. The onClick handler is called 166 | mouse_event.type = .release; 167 | try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 168 | try std.testing.expectEqual(1, foo.count); 169 | 170 | // Send it another press 171 | mouse_event.type = .press; 172 | try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 173 | 174 | // Now the mouse leaves 175 | try b_widget.handleEvent(&ctx, .mouse_leave); 176 | 177 | // Then it comes back. We don't know it but the button was pressed outside of our widget. We 178 | // receie the release event 179 | mouse_event.type = .release; 180 | try b_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 181 | 182 | // But we didn't have the press registered, so we don't call onClick 183 | try std.testing.expectEqual(1, foo.count); 184 | 185 | // Now we receive an enter keypress. This also triggers the onClick handler 186 | try b_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.enter } }); 187 | try std.testing.expectEqual(2, foo.count); 188 | 189 | // Now we draw the button. Set up our context with some unicode data 190 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 191 | defer arena.deinit(); 192 | const ucd = try vaxis.Unicode.init(arena.allocator()); 193 | vtk.DrawContext.init(&ucd, .unicode); 194 | 195 | const draw_ctx: vtk.DrawContext = .{ 196 | .arena = arena.allocator(), 197 | .min = .{}, 198 | .max = .{ .width = 13, .height = 3 }, 199 | }; 200 | const surface = try b_widget.draw(draw_ctx); 201 | 202 | // The button should fill the available space. 203 | try std.testing.expectEqual(surface.size.width, draw_ctx.max.width.?); 204 | try std.testing.expectEqual(surface.size.height, draw_ctx.max.height.?); 205 | 206 | // It should have one child, the label 207 | try std.testing.expectEqual(1, surface.children.len); 208 | 209 | // The label should be centered 210 | try std.testing.expectEqual(1, surface.children[0].origin.row); 211 | try std.testing.expectEqual(1, surface.children[0].origin.col); 212 | } 213 | 214 | test "refAllDecls" { 215 | std.testing.refAllDecls(@This()); 216 | } 217 | -------------------------------------------------------------------------------- /examples/fuzzy.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | const vtk = @import("vtk"); 4 | 5 | const Model = struct { 6 | list: std.ArrayList(vtk.Text), 7 | filtered: std.ArrayList(vtk.RichText), 8 | list_view: vtk.ListView, 9 | text_field: vtk.TextField, 10 | result: []const u8, 11 | unicode_data: *const vaxis.Unicode, 12 | 13 | /// Used for filtered RichText Spans 14 | arena: std.heap.ArenaAllocator, 15 | 16 | pub fn widget(self: *Model) vtk.Widget { 17 | return .{ 18 | .userdata = self, 19 | .eventHandler = Model.typeErasedEventHandler, 20 | .drawFn = Model.typeErasedDrawFn, 21 | }; 22 | } 23 | 24 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 25 | const self: *Model = @ptrCast(@alignCast(ptr)); 26 | switch (event) { 27 | .init => { 28 | // Initialize the filtered list 29 | const allocator = self.arena.allocator(); 30 | for (self.list.items) |line| { 31 | var spans = std.ArrayList(vtk.RichText.TextSpan).init(allocator); 32 | const span: vtk.RichText.TextSpan = .{ .text = line.text }; 33 | try spans.append(span); 34 | try self.filtered.append(.{ .text = spans.items }); 35 | } 36 | 37 | return ctx.requestFocus(self.text_field.widget()); 38 | }, 39 | .key_press => |key| { 40 | if (key.matches('c', .{ .ctrl = true })) { 41 | ctx.quit = true; 42 | return; 43 | } 44 | return self.list_view.handleEvent(ctx, event); 45 | }, 46 | .focus_in => { 47 | return ctx.requestFocus(self.text_field.widget()); 48 | }, 49 | else => {}, 50 | } 51 | } 52 | 53 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) std.mem.Allocator.Error!vtk.Surface { 54 | const self: *Model = @ptrCast(@alignCast(ptr)); 55 | const max = ctx.max.size(); 56 | 57 | var list_view: vtk.SubSurface = .{ 58 | .origin = .{ .row = 2, .col = 0 }, 59 | .surface = try self.list_view.draw(ctx.withConstraints( 60 | ctx.min, 61 | .{ .width = max.width, .height = max.height - 3 }, 62 | )), 63 | }; 64 | list_view.surface.focusable = false; 65 | 66 | const text_field: vtk.SubSurface = .{ 67 | .origin = .{ .row = 0, .col = 2 }, 68 | .surface = try self.text_field.draw(ctx.withConstraints( 69 | ctx.min, 70 | .{ .width = max.width, .height = 1 }, 71 | )), 72 | }; 73 | 74 | const prompt: vtk.Text = .{ .text = "", .style = .{ .fg = .{ .index = 4 } } }; 75 | 76 | const prompt_surface: vtk.SubSurface = .{ 77 | .origin = .{ .row = 0, .col = 0 }, 78 | .surface = try prompt.draw(ctx.withConstraints(ctx.min, .{ .width = 2, .height = 1 })), 79 | }; 80 | 81 | const children = try ctx.arena.alloc(vtk.SubSurface, 3); 82 | children[0] = list_view; 83 | children[1] = text_field; 84 | children[2] = prompt_surface; 85 | 86 | return .{ 87 | .size = max, 88 | .widget = self.widget(), 89 | .focusable = true, 90 | .buffer = &.{}, 91 | .children = children, 92 | }; 93 | } 94 | 95 | fn widgetBuilder(ptr: *const anyopaque, idx: usize, _: usize) ?vtk.Widget { 96 | const self: *const Model = @ptrCast(@alignCast(ptr)); 97 | if (idx >= self.filtered.items.len) return null; 98 | 99 | return self.filtered.items[idx].widget(); 100 | } 101 | 102 | fn onChange(maybe_ptr: ?*anyopaque, _: *vtk.EventContext, str: []const u8) anyerror!void { 103 | const ptr = maybe_ptr orelse return; 104 | const self: *Model = @ptrCast(@alignCast(ptr)); 105 | self.filtered.clearAndFree(); 106 | _ = self.arena.reset(.free_all); 107 | const allocator = self.arena.allocator(); 108 | 109 | const hasUpper = for (str) |b| { 110 | if (std.ascii.isUpper(b)) break true; 111 | } else false; 112 | 113 | // Loop each line 114 | // If our input is only lowercase, we convert the line to lowercase 115 | // Iterate the input graphemes, looking for them _in order_ in the line 116 | outer: for (self.list.items) |item| { 117 | const tgt = if (hasUpper) 118 | item.text 119 | else 120 | try toLower(allocator, item.text); 121 | 122 | var spans = std.ArrayList(vtk.RichText.TextSpan).init(allocator); 123 | var i: usize = 0; 124 | var iter = self.unicode_data.graphemeIterator(str); 125 | while (iter.next()) |g| { 126 | if (std.mem.indexOfPos(u8, tgt, i, g.bytes(str))) |idx| { 127 | const up_to_here: vtk.RichText.TextSpan = .{ .text = item.text[i..idx] }; 128 | const match: vtk.RichText.TextSpan = .{ 129 | .text = item.text[idx .. idx + g.len], 130 | .style = .{ .fg = .{ .index = 4 }, .reverse = true }, 131 | }; 132 | try spans.append(up_to_here); 133 | try spans.append(match); 134 | i = idx + g.len; 135 | } else continue :outer; 136 | } 137 | const up_to_here: vtk.RichText.TextSpan = .{ .text = item.text[i..] }; 138 | try spans.append(up_to_here); 139 | try self.filtered.append(.{ .text = spans.items }); 140 | } 141 | self.list_view.scroll.top = 0; 142 | self.list_view.scroll.offset = 0; 143 | self.list_view.cursor = 0; 144 | } 145 | 146 | fn onSubmit(maybe_ptr: ?*anyopaque, ctx: *vtk.EventContext, _: []const u8) anyerror!void { 147 | const ptr = maybe_ptr orelse return; 148 | const self: *Model = @ptrCast(@alignCast(ptr)); 149 | if (self.list_view.cursor < self.filtered.items.len) { 150 | const selected = self.filtered.items[self.list_view.cursor]; 151 | const allocator = self.arena.allocator(); 152 | var result: std.ArrayListUnmanaged(u8) = .{}; 153 | for (selected.text) |span| { 154 | try result.appendSlice(allocator, span.text); 155 | } 156 | self.result = result.items; 157 | } 158 | ctx.quit = true; 159 | } 160 | }; 161 | 162 | fn toLower(allocator: std.mem.Allocator, src: []const u8) std.mem.Allocator.Error![]const u8 { 163 | const lower = try allocator.alloc(u8, src.len); 164 | for (src, 0..) |b, i| { 165 | lower[i] = std.ascii.toLower(b); 166 | } 167 | return lower; 168 | } 169 | 170 | pub fn main() !void { 171 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 172 | defer _ = gpa.deinit(); 173 | 174 | const allocator = gpa.allocator(); 175 | 176 | var app = try vtk.App.init(allocator); 177 | errdefer app.deinit(); 178 | 179 | const model = try allocator.create(Model); 180 | defer allocator.destroy(model); 181 | model.* = .{ 182 | .list = std.ArrayList(vtk.Text).init(allocator), 183 | .filtered = std.ArrayList(vtk.RichText).init(allocator), 184 | .list_view = .{ 185 | .children = .{ 186 | .builder = .{ 187 | .userdata = model, 188 | .buildFn = Model.widgetBuilder, 189 | }, 190 | }, 191 | }, 192 | .text_field = .{ 193 | .buf = vtk.TextField.Buffer.init(allocator), 194 | .unicode = &app.vx.unicode, 195 | .userdata = model, 196 | .onChange = Model.onChange, 197 | .onSubmit = Model.onSubmit, 198 | }, 199 | .result = "", 200 | .arena = std.heap.ArenaAllocator.init(allocator), 201 | .unicode_data = &app.vx.unicode, 202 | }; 203 | defer model.text_field.deinit(); 204 | defer model.list.deinit(); 205 | defer model.filtered.deinit(); 206 | defer model.arena.deinit(); 207 | 208 | // Run the command 209 | var fd = std.process.Child.init(&.{"fd"}, allocator); 210 | fd.stdout_behavior = .Pipe; 211 | fd.stderr_behavior = .Pipe; 212 | var stdout = std.ArrayList(u8).init(allocator); 213 | var stderr = std.ArrayList(u8).init(allocator); 214 | defer stdout.deinit(); 215 | defer stderr.deinit(); 216 | try fd.spawn(); 217 | try fd.collectOutput(&stdout, &stderr, 10_000_000); 218 | _ = try fd.wait(); 219 | 220 | var iter = std.mem.splitScalar(u8, stdout.items, '\n'); 221 | while (iter.next()) |line| { 222 | if (line.len == 0) continue; 223 | try model.list.append(.{ .text = line }); 224 | } 225 | 226 | try app.run(model.widget(), .{}); 227 | app.deinit(); 228 | 229 | if (model.result.len > 0) { 230 | const writer = std.io.getStdOut().writer(); 231 | try writer.print("{s}\n", .{model.result}); 232 | } else { 233 | std.process.exit(130); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/RichText.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const vtk = @import("main.zig"); 5 | 6 | const Allocator = std.mem.Allocator; 7 | 8 | const RichText = @This(); 9 | 10 | pub const TextSpan = struct { 11 | text: []const u8, 12 | style: vaxis.Style = .{}, 13 | }; 14 | 15 | text: []const TextSpan, 16 | text_align: enum { left, center, right } = .left, 17 | base_style: vaxis.Style = .{}, 18 | softwrap: bool = true, 19 | overflow: enum { ellipsis, clip } = .ellipsis, 20 | width_basis: enum { parent, longest_line } = .longest_line, 21 | 22 | pub fn widget(self: *const RichText) vtk.Widget { 23 | return .{ 24 | .userdata = @constCast(self), 25 | .eventHandler = vtk.noopEventHandler, 26 | .drawFn = typeErasedDrawFn, 27 | }; 28 | } 29 | 30 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 31 | const self: *const RichText = @ptrCast(@alignCast(ptr)); 32 | return self.draw(ctx); 33 | } 34 | 35 | pub fn draw(self: *const RichText, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 36 | var iter = try SoftwrapIterator.init(self.text, ctx); 37 | const container_size = self.findContainerSize(&iter); 38 | 39 | // Create a surface of target width and max height. We'll trim the result after drawing 40 | const surface = try vtk.Surface.init( 41 | ctx.arena, 42 | self.widget(), 43 | container_size, 44 | ); 45 | const base: vaxis.Cell = .{ .style = self.base_style }; 46 | @memset(surface.buffer, base); 47 | 48 | var row: u16 = 0; 49 | if (self.softwrap) { 50 | while (iter.next()) |line| { 51 | if (ctx.max.outsideHeight(row)) break; 52 | defer row += 1; 53 | var col: u16 = switch (self.text_align) { 54 | .left => 0, 55 | .center => (container_size.width - line.width) / 2, 56 | .right => container_size.width - line.width, 57 | }; 58 | for (line.cells) |cell| { 59 | surface.writeCell(col, row, cell); 60 | col += cell.char.width; 61 | } 62 | } 63 | } else { 64 | while (iter.nextHardBreak()) |line| { 65 | if (ctx.max.outsideHeight(row)) break; 66 | const line_width = blk: { 67 | var w: u16 = 0; 68 | for (line) |cell| { 69 | w +|= cell.char.width; 70 | } 71 | break :blk w; 72 | }; 73 | defer row += 1; 74 | var col: u16 = switch (self.text_align) { 75 | .left => 0, 76 | .center => (container_size.width -| line_width) / 2, 77 | .right => container_size.width -| line_width, 78 | }; 79 | for (line) |cell| { 80 | if (col + cell.char.width >= container_size.width and 81 | line_width > container_size.width and 82 | self.overflow == .ellipsis) 83 | { 84 | surface.writeCell(col, row, .{ 85 | .char = .{ .grapheme = "…", .width = 1 }, 86 | }); 87 | col = container_size.width; 88 | continue; 89 | } else { 90 | surface.writeCell(col, row, cell); 91 | col += @intCast(cell.char.width); 92 | } 93 | } 94 | } 95 | } 96 | return surface.trimHeight(@max(row, ctx.min.height)); 97 | } 98 | 99 | /// Finds the widest line within the viewable portion of ctx 100 | fn findContainerSize(self: RichText, iter: *SoftwrapIterator) vtk.Size { 101 | defer iter.reset(); 102 | var row: u16 = 0; 103 | var max_width: u16 = iter.ctx.min.width; 104 | if (self.softwrap) { 105 | while (iter.next()) |line| { 106 | if (iter.ctx.max.outsideHeight(row)) break; 107 | defer row += 1; 108 | max_width = @max(max_width, line.width); 109 | } 110 | } else { 111 | while (iter.nextHardBreak()) |line| { 112 | if (iter.ctx.max.outsideHeight(row)) break; 113 | defer row += 1; 114 | var w: u16 = 0; 115 | for (line) |cell| { 116 | w +|= cell.char.width; 117 | } 118 | max_width = @max(max_width, w); 119 | } 120 | } 121 | const result_width = switch (self.width_basis) { 122 | .longest_line => blk: { 123 | if (iter.ctx.max.width) |max| 124 | break :blk @min(max, max_width) 125 | else 126 | break :blk max_width; 127 | }, 128 | .parent => blk: { 129 | std.debug.assert(iter.ctx.max.width != null); 130 | break :blk iter.ctx.max.width.?; 131 | }, 132 | }; 133 | return .{ .width = result_width, .height = @max(row, iter.ctx.min.height) }; 134 | } 135 | 136 | pub const SoftwrapIterator = struct { 137 | arena: std.heap.ArenaAllocator, 138 | ctx: vtk.DrawContext, 139 | text: []const vaxis.Cell, 140 | line: []const vaxis.Cell, 141 | index: usize = 0, 142 | // Index of the hard iterator 143 | hard_index: usize = 0, 144 | 145 | const soft_breaks = " \t"; 146 | 147 | pub const Line = struct { 148 | width: u16, 149 | cells: []const vaxis.Cell, 150 | }; 151 | 152 | fn init(spans: []const TextSpan, ctx: vtk.DrawContext) Allocator.Error!SoftwrapIterator { 153 | // Estimate the number of cells we need 154 | var len: usize = 0; 155 | for (spans) |span| { 156 | len += span.text.len; 157 | } 158 | var arena = std.heap.ArenaAllocator.init(ctx.arena); 159 | var list = try std.ArrayList(vaxis.Cell).initCapacity(arena.allocator(), len); 160 | 161 | for (spans) |span| { 162 | var iter = ctx.graphemeIterator(span.text); 163 | while (iter.next()) |grapheme| { 164 | const char = grapheme.bytes(span.text); 165 | const width = ctx.stringWidth(char); 166 | const cell: vaxis.Cell = .{ 167 | .char = .{ .grapheme = char, .width = @intCast(width) }, 168 | .style = span.style, 169 | }; 170 | try list.append(cell); 171 | } 172 | } 173 | return .{ 174 | .arena = arena, 175 | .ctx = ctx, 176 | .text = list.items, 177 | .line = &.{}, 178 | }; 179 | } 180 | 181 | fn reset(self: *SoftwrapIterator) void { 182 | self.index = 0; 183 | self.hard_index = 0; 184 | self.line = &.{}; 185 | } 186 | 187 | fn deinit(self: *SoftwrapIterator) void { 188 | self.arena.deinit(); 189 | } 190 | 191 | fn nextHardBreak(self: *SoftwrapIterator) ?[]const vaxis.Cell { 192 | if (self.hard_index >= self.text.len) return null; 193 | const start = self.hard_index; 194 | var saw_cr: bool = false; 195 | while (self.hard_index < self.text.len) : (self.hard_index += 1) { 196 | const cell = self.text[self.hard_index]; 197 | if (std.mem.eql(u8, cell.char.grapheme, "\r")) { 198 | saw_cr = true; 199 | } 200 | if (std.mem.eql(u8, cell.char.grapheme, "\n")) { 201 | self.hard_index += 1; 202 | if (saw_cr) { 203 | return self.text[start .. self.hard_index - 2]; 204 | } 205 | return self.text[start .. self.hard_index - 1]; 206 | } 207 | if (saw_cr) { 208 | // back up one 209 | self.hard_index -= 1; 210 | return self.text[start .. self.hard_index - 1]; 211 | } 212 | } else return self.text[start..]; 213 | } 214 | 215 | fn trimWSPRight(text: []const vaxis.Cell) []const vaxis.Cell { 216 | // trim linear whitespace 217 | var i: usize = text.len; 218 | while (i > 0) : (i -= 1) { 219 | if (std.mem.eql(u8, text[i - 1].char.grapheme, " ") or 220 | std.mem.eql(u8, text[i - 1].char.grapheme, "\t")) 221 | { 222 | continue; 223 | } 224 | break; 225 | } 226 | return text[0..i]; 227 | } 228 | 229 | fn trimWSPLeft(text: []const vaxis.Cell) []const vaxis.Cell { 230 | // trim linear whitespace 231 | var i: usize = 0; 232 | while (i < text.len) : (i += 1) { 233 | if (std.mem.eql(u8, text[i].char.grapheme, " ") or 234 | std.mem.eql(u8, text[i].char.grapheme, "\t")) 235 | { 236 | continue; 237 | } 238 | break; 239 | } 240 | return text[i..]; 241 | } 242 | 243 | fn next(self: *SoftwrapIterator) ?Line { 244 | // Advance the hard iterator 245 | if (self.index == self.line.len) { 246 | self.line = self.nextHardBreak() orelse return null; 247 | // trim linear whitespace 248 | self.line = trimWSPRight(self.line); 249 | self.index = 0; 250 | } 251 | 252 | const max_width = self.ctx.max.width orelse { 253 | var width: u16 = 0; 254 | for (self.line) |cell| { 255 | width += cell.char.width; 256 | } 257 | self.index = self.line.len; 258 | return .{ 259 | .width = width, 260 | .cells = self.line, 261 | }; 262 | }; 263 | 264 | const start = self.index; 265 | var cur_width: u16 = 0; 266 | while (self.index < self.line.len) { 267 | // Find the width from current position to next word break 268 | const idx = self.nextWrap(); 269 | const word = self.line[self.index..idx]; 270 | const next_width = blk: { 271 | var w: usize = 0; 272 | for (word) |ch| { 273 | w += ch.char.width; 274 | } 275 | break :blk w; 276 | }; 277 | 278 | if (cur_width + next_width > max_width) { 279 | // Trim the word to see if it can fit on a line by itself 280 | const trimmed = trimWSPLeft(word); 281 | const trimmed_width = next_width - trimmed.len; 282 | if (trimmed_width > max_width) { 283 | // Won't fit on line by itself, so fit as much on this line as we can 284 | for (word) |cell| { 285 | if (cur_width + cell.char.width > max_width) { 286 | const end = self.index; 287 | return .{ .width = cur_width, .cells = self.line[start..end] }; 288 | } 289 | cur_width += @intCast(cell.char.width); 290 | self.index += 1; 291 | } 292 | } 293 | const end = self.index; 294 | // We are softwrapping, advance index to the start of the next word. This is equal 295 | // to the difference in our word length and trimmed word length 296 | self.index += (word.len - trimmed.len); 297 | return .{ .width = cur_width, .cells = self.line[start..end] }; 298 | } 299 | 300 | self.index = idx; 301 | cur_width += @intCast(next_width); 302 | } 303 | return .{ .width = cur_width, .cells = self.line[start..] }; 304 | } 305 | 306 | fn nextWrap(self: *SoftwrapIterator) usize { 307 | var i: usize = self.index; 308 | 309 | // Find the first non-whitespace character 310 | while (i < self.line.len) : (i += 1) { 311 | if (std.mem.eql(u8, self.line[i].char.grapheme, " ") or 312 | std.mem.eql(u8, self.line[i].char.grapheme, "\t")) 313 | { 314 | continue; 315 | } 316 | break; 317 | } 318 | 319 | // Now find the first whitespace 320 | while (i < self.line.len) : (i += 1) { 321 | if (std.mem.eql(u8, self.line[i].char.grapheme, " ") or 322 | std.mem.eql(u8, self.line[i].char.grapheme, "\t")) 323 | { 324 | return i; 325 | } 326 | continue; 327 | } 328 | 329 | return self.line.len; 330 | } 331 | }; 332 | 333 | test RichText { 334 | var rich_text: RichText = .{ 335 | .text = &.{ 336 | .{ .text = "Hello, " }, 337 | .{ .text = "World", .style = .{ .bold = true } }, 338 | }, 339 | }; 340 | 341 | const rich_widget = rich_text.widget(); 342 | 343 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 344 | defer arena.deinit(); 345 | const ucd = try vaxis.Unicode.init(arena.allocator()); 346 | vtk.DrawContext.init(&ucd, .unicode); 347 | 348 | // Center expands to the max size. It must therefore have non-null max width and max height. 349 | // These values are asserted in draw 350 | const ctx: vtk.DrawContext = .{ 351 | .arena = arena.allocator(), 352 | .min = .{}, 353 | .max = .{ .width = 7, .height = 2 }, 354 | }; 355 | 356 | { 357 | // RichText softwraps by default 358 | const surface = try rich_widget.draw(ctx); 359 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 6, .height = 2 }), surface.size); 360 | } 361 | 362 | { 363 | rich_text.softwrap = false; 364 | rich_text.overflow = .ellipsis; 365 | const surface = try rich_widget.draw(ctx); 366 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 7, .height = 1 }), surface.size); 367 | // The last character will be an ellipsis 368 | try std.testing.expectEqualStrings("…", surface.buffer[surface.buffer.len - 1].char.grapheme); 369 | } 370 | } 371 | 372 | test "refAllDecls" { 373 | std.testing.refAllDecls(@This()); 374 | } 375 | -------------------------------------------------------------------------------- /src/App.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | const vtk = @import("main.zig"); 4 | 5 | const assert = std.debug.assert; 6 | 7 | const Allocator = std.mem.Allocator; 8 | 9 | const Canvas = vtk.Canvas; 10 | const EventLoop = vaxis.Loop(vtk.Event); 11 | const Widget = vtk.Widget; 12 | 13 | const App = @This(); 14 | 15 | quit_key: vaxis.Key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } }, 16 | 17 | allocator: Allocator, 18 | tty: vaxis.Tty, 19 | vx: vaxis.Vaxis, 20 | timers: std.ArrayList(vtk.Tick), 21 | wants_focus: ?vtk.Widget, 22 | 23 | /// Runtime options 24 | pub const Options = struct { 25 | /// Frames per second 26 | framerate: u8 = 60, 27 | }; 28 | 29 | /// Create an application. We require stable pointers to do the set up, so this will create an App 30 | /// object on the heap. Call destroy when the app is complete to reset terminal state and release 31 | /// resources 32 | pub fn init(allocator: Allocator) !App { 33 | return .{ 34 | .allocator = allocator, 35 | .tty = try vaxis.Tty.init(), 36 | .vx = try vaxis.init(allocator, .{ .system_clipboard_allocator = allocator }), 37 | .timers = std.ArrayList(vtk.Tick).init(allocator), 38 | .wants_focus = null, 39 | }; 40 | } 41 | 42 | pub fn deinit(self: *App) void { 43 | self.timers.deinit(); 44 | self.vx.deinit(self.allocator, self.tty.anyWriter()); 45 | self.tty.deinit(); 46 | } 47 | 48 | pub fn run(self: *App, widget: vtk.Widget, opts: Options) anyerror!void { 49 | const tty = &self.tty; 50 | const vx = &self.vx; 51 | 52 | var loop: EventLoop = .{ .tty = tty, .vaxis = vx }; 53 | try loop.start(); 54 | defer loop.stop(); 55 | 56 | // Send the init event 57 | loop.postEvent(.init); 58 | 59 | try vx.enterAltScreen(tty.anyWriter()); 60 | try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 61 | 62 | { 63 | // This part deserves a comment. loop.init installs a signal handler for the tty. We wait to 64 | // init the loop until we know if we need this handler. We don't need it if the terminal 65 | // supports in-band-resize 66 | if (!vx.state.in_band_resize) try loop.init(); 67 | } 68 | 69 | // HACK: Ghostty is reporting incorrect pixel screen size 70 | vx.caps.sgr_pixels = false; 71 | try vx.setMouseMode(tty.anyWriter(), true); 72 | 73 | // Give DrawContext the unicode data 74 | vtk.DrawContext.init(&vx.unicode, vx.screen.width_method); 75 | 76 | const framerate: u64 = if (opts.framerate > 0) opts.framerate else 60; 77 | // Calculate tick rate 78 | const tick_ms: u64 = @divFloor(std.time.ms_per_s, framerate); 79 | 80 | // Set up arena and context 81 | var arena = std.heap.ArenaAllocator.init(self.allocator); 82 | defer arena.deinit(); 83 | 84 | var buffered = tty.bufferedWriter(); 85 | 86 | var mouse_handler = MouseHandler.init(widget); 87 | var focus_handler = FocusHandler.init(self.allocator, widget); 88 | focus_handler.intrusiveInit(); 89 | defer focus_handler.deinit(); 90 | 91 | // Timestamp of our next frame 92 | var next_frame_ms: u64 = @intCast(std.time.milliTimestamp()); 93 | 94 | // Create our event context 95 | var ctx: vtk.EventContext = .{ 96 | .phase = .at_target, 97 | .cmds = vtk.CommandList.init(self.allocator), 98 | .consume_event = false, 99 | .redraw = false, 100 | .quit = false, 101 | }; 102 | defer ctx.cmds.deinit(); 103 | 104 | while (true) { 105 | const now_ms: u64 = @intCast(std.time.milliTimestamp()); 106 | if (now_ms >= next_frame_ms) { 107 | // Deadline exceeded. Schedule the next frame 108 | next_frame_ms = now_ms + tick_ms; 109 | } else { 110 | // Sleep until the deadline 111 | std.time.sleep((next_frame_ms - now_ms) * std.time.ns_per_ms); 112 | next_frame_ms += tick_ms; 113 | } 114 | 115 | try self.checkTimers(&ctx); 116 | 117 | while (loop.tryEvent()) |event| { 118 | ctx.consume_event = false; 119 | switch (event) { 120 | .key_press => |key| { 121 | try focus_handler.handleEvent(&ctx, event); 122 | try self.handleCommand(&ctx.cmds); 123 | if (!ctx.consume_event) { 124 | if (key.matches(self.quit_key.codepoint, self.quit_key.mods)) { 125 | ctx.quit = true; 126 | } 127 | if (key.matches(vaxis.Key.tab, .{})) { 128 | try focus_handler.focusNext(&ctx); 129 | try self.handleCommand(&ctx.cmds); 130 | } 131 | if (key.matches(vaxis.Key.tab, .{ .shift = true })) { 132 | try focus_handler.focusPrev(&ctx); 133 | try self.handleCommand(&ctx.cmds); 134 | } 135 | } 136 | }, 137 | .focus_out => try mouse_handler.mouseExit(self, &ctx), 138 | .mouse => |mouse| try mouse_handler.handleMouse(self, &ctx, mouse), 139 | .winsize => |ws| { 140 | try vx.resize(self.allocator, buffered.writer().any(), ws); 141 | try buffered.flush(); 142 | ctx.redraw = true; 143 | }, 144 | else => { 145 | try widget.handleEvent(&ctx, event); 146 | try self.handleCommand(&ctx.cmds); 147 | }, 148 | } 149 | } 150 | 151 | // Check if we should quit 152 | if (ctx.quit) return; 153 | 154 | // Check if we need a redraw 155 | if (!ctx.redraw) continue; 156 | ctx.redraw = false; 157 | // Assert that we have handled all commands 158 | assert(ctx.cmds.items.len == 0); 159 | 160 | _ = arena.reset(.retain_capacity); 161 | 162 | const draw_context: vtk.DrawContext = .{ 163 | .arena = arena.allocator(), 164 | .min = .{ .width = 0, .height = 0 }, 165 | .max = .{ 166 | .width = @intCast(vx.screen.width), 167 | .height = @intCast(vx.screen.height), 168 | }, 169 | }; 170 | const win = vx.window(); 171 | win.clear(); 172 | win.hideCursor(); 173 | win.setCursorShape(.default); 174 | const surface = try widget.draw(draw_context); 175 | 176 | const appwin: vtk.Window = .{ 177 | .x_off = 0, 178 | .y_off = 0, 179 | .width = @intCast(win.screen.width), 180 | .height = @intCast(win.screen.height), 181 | .screen = win.screen, 182 | }; 183 | 184 | const focused = self.wants_focus orelse focus_handler.focused.widget; 185 | surface.render(appwin, focused); 186 | try vx.render(buffered.writer().any()); 187 | try buffered.flush(); 188 | 189 | // Store the last frame 190 | mouse_handler.last_frame = surface; 191 | try focus_handler.update(surface, self.wants_focus); 192 | self.wants_focus = null; 193 | } 194 | } 195 | 196 | fn addTick(self: *App, tick: vtk.Tick) Allocator.Error!void { 197 | try self.timers.append(tick); 198 | std.sort.insertion(vtk.Tick, self.timers.items, {}, vtk.Tick.lessThan); 199 | } 200 | 201 | fn handleCommand(self: *App, cmds: *vtk.CommandList) Allocator.Error!void { 202 | defer cmds.clearRetainingCapacity(); 203 | for (cmds.items) |cmd| { 204 | switch (cmd) { 205 | .tick => |tick| try self.addTick(tick), 206 | .set_mouse_shape => |shape| self.vx.setMouseShape(shape), 207 | .request_focus => |widget| self.wants_focus = widget, 208 | } 209 | } 210 | } 211 | 212 | fn checkTimers(self: *App, ctx: *vtk.EventContext) anyerror!void { 213 | const now_ms = std.time.milliTimestamp(); 214 | 215 | // timers are always sorted descending 216 | while (self.timers.popOrNull()) |tick| { 217 | if (now_ms < tick.deadline_ms) 218 | break; 219 | try tick.widget.handleEvent(ctx, .tick); 220 | try self.handleCommand(&ctx.cmds); 221 | } 222 | } 223 | 224 | const MouseHandler = struct { 225 | last_frame: vtk.Surface, 226 | maybe_last_handler: ?vtk.Widget = null, 227 | 228 | fn init(root: Widget) MouseHandler { 229 | return .{ 230 | .last_frame = .{ 231 | .size = .{ .width = 0, .height = 0 }, 232 | .widget = root, 233 | .buffer = &.{}, 234 | .children = &.{}, 235 | }, 236 | .maybe_last_handler = null, 237 | }; 238 | } 239 | 240 | fn handleMouse(self: *MouseHandler, app: *App, ctx: *vtk.EventContext, mouse: vaxis.Mouse) anyerror!void { 241 | const last_frame = self.last_frame; 242 | 243 | // For mouse events we store the last frame and use that for hit testing 244 | var hits = std.ArrayList(vtk.HitResult).init(app.allocator); 245 | defer hits.deinit(); 246 | const sub: vtk.SubSurface = .{ 247 | .origin = .{ .row = 0, .col = 0 }, 248 | .surface = last_frame, 249 | .z_index = 0, 250 | }; 251 | const mouse_point: vtk.Point = .{ 252 | .row = @intCast(mouse.row), 253 | .col = @intCast(mouse.col), 254 | }; 255 | if (sub.containsPoint(mouse_point)) { 256 | try last_frame.hitTest(&hits, mouse_point); 257 | } 258 | while (hits.popOrNull()) |item| { 259 | var m_local = mouse; 260 | m_local.col = item.local.col; 261 | m_local.row = item.local.row; 262 | try item.widget.handleEvent(ctx, .{ .mouse = m_local }); 263 | try app.handleCommand(&ctx.cmds); 264 | 265 | // If the event wasn't consumed, we keep passing it on 266 | if (!ctx.consume_event) continue; 267 | 268 | if (self.maybe_last_handler) |last_mouse_handler| { 269 | if (!last_mouse_handler.eql(item.widget)) { 270 | try last_mouse_handler.handleEvent(ctx, .mouse_leave); 271 | try app.handleCommand(&ctx.cmds); 272 | } 273 | } 274 | self.maybe_last_handler = item.widget; 275 | return; 276 | } 277 | 278 | // If no one handled the mouse, we assume it exited 279 | return self.mouseExit(app, ctx); 280 | } 281 | 282 | fn mouseExit(self: *MouseHandler, app: *App, ctx: *vtk.EventContext) anyerror!void { 283 | if (self.maybe_last_handler) |last_handler| { 284 | try last_handler.handleEvent(ctx, .mouse_leave); 285 | try app.handleCommand(&ctx.cmds); 286 | self.maybe_last_handler = null; 287 | } 288 | } 289 | }; 290 | 291 | /// Maintains a tree of focusable nodes. Delivers events to the currently focused node, walking up 292 | /// the tree until the event is handled 293 | const FocusHandler = struct { 294 | arena: std.heap.ArenaAllocator, 295 | 296 | root: Node, 297 | focused: *Node, 298 | maybe_wants_focus: ?vtk.Widget = null, 299 | 300 | const Node = struct { 301 | widget: Widget, 302 | parent: ?*Node, 303 | children: []*Node, 304 | 305 | fn nextSibling(self: Node) ?*Node { 306 | const parent = self.parent orelse return null; 307 | const idx = for (0..parent.children.len) |i| { 308 | const node = parent.children[i]; 309 | if (self.widget.eql(node.widget)) 310 | break i; 311 | } else unreachable; 312 | 313 | // Return null if last child 314 | if (idx == parent.children.len - 1) 315 | return null 316 | else 317 | return parent.children[idx + 1]; 318 | } 319 | 320 | fn prevSibling(self: Node) ?*Node { 321 | const parent = self.parent orelse return null; 322 | const idx = for (0..parent.children.len) |i| { 323 | const node = parent.children[i]; 324 | if (self.widget.eql(node.widget)) 325 | break i; 326 | } else unreachable; 327 | 328 | // Return null if first child 329 | if (idx == 0) 330 | return null 331 | else 332 | return parent.children[idx - 1]; 333 | } 334 | 335 | fn lastChild(self: Node) ?*Node { 336 | if (self.children.len > 0) 337 | return self.children[self.children.len - 1] 338 | else 339 | return null; 340 | } 341 | 342 | fn firstChild(self: Node) ?*Node { 343 | if (self.children.len > 0) 344 | return self.children[0] 345 | else 346 | return null; 347 | } 348 | 349 | /// returns the next logical node in the tree 350 | fn nextNode(self: *Node) *Node { 351 | // If we have a sibling, we return it's first descendant line 352 | if (self.nextSibling()) |sibling| { 353 | var node = sibling; 354 | while (node.firstChild()) |child| { 355 | node = child; 356 | } 357 | return node; 358 | } 359 | 360 | // If we don't have a sibling, we return our parent 361 | if (self.parent) |parent| return parent; 362 | 363 | // If we don't have a parent, we are the root and we return or first descendant 364 | var node = self; 365 | while (node.firstChild()) |child| { 366 | node = child; 367 | } 368 | return node; 369 | } 370 | 371 | fn prevNode(self: *Node) *Node { 372 | // If we have children, we return the last child descendant 373 | if (self.children.len > 0) { 374 | var node = self; 375 | while (node.lastChild()) |child| { 376 | node = child; 377 | } 378 | return node; 379 | } 380 | 381 | // If we have siblings, we return the last descendant line of the sibling 382 | if (self.prevSibling()) |sibling| { 383 | var node = sibling; 384 | while (node.lastChild()) |child| { 385 | node = child; 386 | } 387 | return node; 388 | } 389 | 390 | // If we don't have a sibling, we return our parent 391 | if (self.parent) |parent| return parent; 392 | 393 | // If we don't have a parent, we are the root and we return our last descendant 394 | var node = self; 395 | while (node.lastChild()) |child| { 396 | node = child; 397 | } 398 | return node; 399 | } 400 | }; 401 | 402 | fn init(allocator: Allocator, root: Widget) FocusHandler { 403 | const node: Node = .{ 404 | .widget = root, 405 | .parent = null, 406 | .children = &.{}, 407 | }; 408 | return .{ 409 | .root = node, 410 | .focused = undefined, 411 | .arena = std.heap.ArenaAllocator.init(allocator), 412 | .maybe_wants_focus = null, 413 | }; 414 | } 415 | 416 | fn intrusiveInit(self: *FocusHandler) void { 417 | self.focused = &self.root; 418 | } 419 | 420 | fn deinit(self: *FocusHandler) void { 421 | self.arena.deinit(); 422 | } 423 | 424 | /// Update the focus list 425 | fn update(self: *FocusHandler, root: vtk.Surface, maybe_wants_focus: ?vtk.Widget) Allocator.Error!void { 426 | _ = self.arena.reset(.retain_capacity); 427 | self.maybe_wants_focus = maybe_wants_focus; 428 | 429 | var list = std.ArrayList(*Node).init(self.arena.allocator()); 430 | for (root.children) |child| { 431 | try self.findFocusableChildren(&self.root, &list, child.surface); 432 | } 433 | self.root = .{ 434 | .widget = root.widget, 435 | .children = list.items, 436 | .parent = null, 437 | }; 438 | } 439 | 440 | /// Walks the surface tree, adding all focusable nodes to list 441 | fn findFocusableChildren( 442 | self: *FocusHandler, 443 | parent: *Node, 444 | list: *std.ArrayList(*Node), 445 | surface: vtk.Surface, 446 | ) Allocator.Error!void { 447 | if (surface.focusable) { 448 | // We are a focusable child of parent. Create a new node, and find our own focusable 449 | // children 450 | const node = try self.arena.allocator().create(Node); 451 | var child_list = std.ArrayList(*Node).init(self.arena.allocator()); 452 | for (surface.children) |child| { 453 | try self.findFocusableChildren(node, &child_list, child.surface); 454 | } 455 | node.* = .{ 456 | .widget = surface.widget, 457 | .parent = parent, 458 | .children = child_list.items, 459 | }; 460 | if (self.maybe_wants_focus) |wants_focus| { 461 | if (wants_focus.eql(surface.widget)) { 462 | self.focused = node; 463 | self.maybe_wants_focus = null; 464 | } 465 | } 466 | try list.append(node); 467 | } else { 468 | for (surface.children) |child| { 469 | try self.findFocusableChildren(parent, list, child.surface); 470 | } 471 | } 472 | } 473 | 474 | fn focusNode(self: *FocusHandler, ctx: *vtk.EventContext, node: *Node) anyerror!void { 475 | if (self.focused.widget.eql(node.widget)) return; 476 | 477 | try self.focused.widget.handleEvent(ctx, .focus_out); 478 | self.focused = node; 479 | try self.focused.widget.handleEvent(ctx, .focus_in); 480 | } 481 | 482 | /// Focuses the next focusable widget 483 | fn focusNext(self: *FocusHandler, ctx: *vtk.EventContext) anyerror!void { 484 | return self.focusNode(ctx, self.focused.nextNode()); 485 | } 486 | 487 | /// Focuses the previous focusable widget 488 | fn focusPrev(self: *FocusHandler, ctx: *vtk.EventContext) anyerror!void { 489 | return self.focusNode(ctx, self.focused.prevNode()); 490 | } 491 | 492 | fn handleEvent(self: *FocusHandler, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 493 | var maybe_node: ?*Node = self.focused; 494 | while (maybe_node) |node| { 495 | try node.widget.handleEvent(ctx, event); 496 | if (ctx.consume_event) return; 497 | maybe_node = node.parent; 498 | } 499 | } 500 | }; 501 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | pub const vaxis = @import("vaxis"); 3 | 4 | const grapheme = vaxis.grapheme; 5 | 6 | const assert = std.debug.assert; 7 | const testing = std.testing; 8 | 9 | const Allocator = std.mem.Allocator; 10 | pub const CommandList = std.ArrayList(Command); 11 | 12 | pub const App = @import("App.zig"); 13 | 14 | // Layout widgets 15 | pub const Center = @import("Center.zig"); 16 | pub const FlexColumn = @import("FlexColumn.zig"); 17 | pub const FlexRow = @import("FlexRow.zig"); 18 | pub const Padding = @import("Padding.zig"); 19 | pub const SizedBox = @import("SizedBox.zig"); 20 | 21 | // Interactive 22 | pub const Button = @import("Button.zig"); 23 | pub const ListView = @import("ListView.zig"); 24 | pub const TextField = @import("TextField.zig"); 25 | 26 | // Animated 27 | pub const Spinner = @import("Spinner.zig"); 28 | 29 | // Static 30 | pub const RichText = @import("RichText.zig"); 31 | pub const Text = @import("Text.zig"); 32 | 33 | const log = std.log.scoped(.vtk); 34 | 35 | pub const AppEvent = struct { 36 | name: []const u8, 37 | data: ?*const anyopaque = null, 38 | }; 39 | 40 | pub const Event = union(enum) { 41 | key_press: vaxis.Key, 42 | key_release: vaxis.Key, 43 | mouse: vaxis.Mouse, 44 | focus_in, // window has gained focus 45 | focus_out, // window has lost focus 46 | paste_start, // bracketed paste start 47 | paste_end, // bracketed paste end 48 | paste: []const u8, // osc 52 paste, caller must free 49 | color_report: vaxis.Color.Report, // osc 4, 10, 11, 12 response 50 | color_scheme: vaxis.Color.Scheme, // light / dark OS theme changes 51 | winsize: vaxis.Winsize, // the window size has changed. This event is always sent when the loop is started 52 | app: AppEvent, // A custom event from the app 53 | tick, // An event from a Tick command 54 | init, // sent when the application starts 55 | mouse_leave, // The mouse has left the widget 56 | }; 57 | 58 | pub const Tick = struct { 59 | deadline_ms: i64, 60 | widget: Widget, 61 | 62 | pub fn lessThan(_: void, lhs: Tick, rhs: Tick) bool { 63 | return lhs.deadline_ms > rhs.deadline_ms; 64 | } 65 | 66 | pub fn in(ms: u32, widget: Widget) Command { 67 | const now = std.time.milliTimestamp(); 68 | return .{ .tick = .{ 69 | .deadline_ms = now + ms, 70 | .widget = widget, 71 | } }; 72 | } 73 | }; 74 | 75 | pub const Command = union(enum) { 76 | /// Callback the event with a tick event at the specified deadlline 77 | tick: Tick, 78 | /// Change the mouse shape. This also has an implicit redraw 79 | set_mouse_shape: vaxis.Mouse.Shape, 80 | /// Request that this widget receives focus 81 | request_focus: Widget, 82 | }; 83 | 84 | pub const EventContext = struct { 85 | phase: Phase = .at_target, 86 | cmds: CommandList, 87 | 88 | /// The event was handled, do not pass it on 89 | consume_event: bool = false, 90 | /// Tells the event loop to redraw the UI 91 | redraw: bool = true, 92 | /// Quit the application 93 | quit: bool = false, 94 | 95 | pub const Phase = enum { 96 | // TODO: Capturing phase 97 | // capturing, 98 | at_target, 99 | bubbling, 100 | }; 101 | 102 | pub fn addCmd(self: *EventContext, cmd: Command) Allocator.Error!void { 103 | try self.cmds.append(cmd); 104 | } 105 | 106 | pub fn tick(self: *EventContext, ms: u32, widget: Widget) Allocator.Error!void { 107 | try self.addCmd(Tick.in(ms, widget)); 108 | } 109 | 110 | pub fn consumeAndRedraw(self: *EventContext) void { 111 | self.consume_event = true; 112 | self.redraw = true; 113 | } 114 | 115 | pub fn consumeEvent(self: *EventContext) void { 116 | self.consume_event = true; 117 | } 118 | 119 | pub fn setMouseShape(self: *EventContext, shape: vaxis.Mouse.Shape) Allocator.Error!void { 120 | try self.addCmd(.{ .set_mouse_shape = shape }); 121 | self.redraw = true; 122 | } 123 | 124 | pub fn requestFocus(self: *EventContext, widget: Widget) Allocator.Error!void { 125 | try self.addCmd(.{ .request_focus = widget }); 126 | } 127 | }; 128 | 129 | pub const DrawContext = struct { 130 | // Allocator backed by an arena. Widgets do not need to free their own resources, they will be 131 | // freed after rendering 132 | arena: std.mem.Allocator, 133 | // Constraints 134 | min: Size, 135 | max: MaxSize, 136 | 137 | // Unicode stuff 138 | var unicode: ?*const vaxis.Unicode = null; 139 | var width_method: vaxis.gwidth.Method = .unicode; 140 | 141 | pub fn init(ucd: *const vaxis.Unicode, method: vaxis.gwidth.Method) void { 142 | DrawContext.unicode = ucd; 143 | DrawContext.width_method = method; 144 | } 145 | 146 | pub fn stringWidth(_: DrawContext, str: []const u8) usize { 147 | assert(DrawContext.unicode != null); // DrawContext not initialized 148 | return vaxis.gwidth.gwidth( 149 | str, 150 | DrawContext.width_method, 151 | &DrawContext.unicode.?.width_data, 152 | ); 153 | } 154 | 155 | pub fn graphemeIterator(_: DrawContext, str: []const u8) grapheme.Iterator { 156 | assert(DrawContext.unicode != null); // DrawContext not initialized 157 | return DrawContext.unicode.?.graphemeIterator(str); 158 | } 159 | 160 | pub fn withConstraints(self: DrawContext, min: Size, max: MaxSize) DrawContext { 161 | return .{ 162 | .arena = self.arena, 163 | .min = min, 164 | .max = max, 165 | }; 166 | } 167 | }; 168 | 169 | pub const Size = struct { 170 | width: u16 = 0, 171 | height: u16 = 0, 172 | }; 173 | 174 | pub const MaxSize = struct { 175 | width: ?u16 = null, 176 | height: ?u16 = null, 177 | 178 | /// Returns true if the row would fall outside of this height. A null height value is infinite 179 | /// and always returns false 180 | pub fn outsideHeight(self: MaxSize, row: u16) bool { 181 | const max = self.height orelse return false; 182 | return row >= max; 183 | } 184 | 185 | /// Returns true if the col would fall outside of this width. A null width value is infinite 186 | /// and always returns false 187 | pub fn outsideWidth(self: MaxSize, col: u16) bool { 188 | const max = self.width orelse return false; 189 | return col >= max; 190 | } 191 | 192 | /// Asserts that neither height nor width are null 193 | pub fn size(self: MaxSize) Size { 194 | assert(self.width != null); 195 | assert(self.height != null); 196 | return .{ 197 | .width = self.width.?, 198 | .height = self.height.?, 199 | }; 200 | } 201 | }; 202 | 203 | /// The Widget interface 204 | pub const Widget = struct { 205 | userdata: *anyopaque, 206 | eventHandler: *const fn (userdata: *anyopaque, ctx: *EventContext, event: Event) anyerror!void, 207 | drawFn: *const fn (userdata: *anyopaque, ctx: DrawContext) Allocator.Error!Surface, 208 | 209 | pub fn handleEvent(self: Widget, ctx: *EventContext, event: Event) anyerror!void { 210 | return self.eventHandler(self.userdata, ctx, event); 211 | } 212 | 213 | pub fn draw(self: Widget, ctx: DrawContext) Allocator.Error!Surface { 214 | return self.drawFn(self.userdata, ctx); 215 | } 216 | 217 | /// Returns true if the Widgets point to the same widget instance 218 | pub fn eql(self: Widget, other: Widget) bool { 219 | return @intFromPtr(self.userdata) == @intFromPtr(other.userdata) and 220 | @intFromPtr(self.eventHandler) == @intFromPtr(other.eventHandler) and 221 | @intFromPtr(self.drawFn) == @intFromPtr(other.drawFn); 222 | } 223 | }; 224 | 225 | pub const FlexItem = struct { 226 | widget: Widget, 227 | /// A value of zero means the child will have it's inherent size. Any value greater than zero 228 | /// and the remaining space will be proportioned to each item 229 | flex: u8 = 1, 230 | 231 | pub fn init(child: Widget, flex: u8) FlexItem { 232 | return .{ .widget = child, .flex = flex }; 233 | } 234 | }; 235 | 236 | pub const Point = struct { 237 | row: u16, 238 | col: u16, 239 | }; 240 | 241 | pub const RelativePoint = struct { 242 | row: i32, 243 | col: i32, 244 | }; 245 | 246 | /// Result of a hit test 247 | pub const HitResult = struct { 248 | local: Point, 249 | widget: Widget, 250 | }; 251 | 252 | pub const CursorState = struct { 253 | /// Local coordinates 254 | row: u16, 255 | /// Local coordinates 256 | col: u16, 257 | shape: vaxis.Cell.CursorShape = .default, 258 | }; 259 | 260 | pub const Surface = struct { 261 | /// Size of this surface 262 | size: Size, 263 | /// The widget this surface belongs to 264 | widget: Widget, 265 | 266 | /// If this widget / Surface is focusable 267 | focusable: bool = false, 268 | /// If this widget can handle mouse events 269 | handles_mouse: bool = false, 270 | 271 | /// Cursor state 272 | cursor: ?CursorState = null, 273 | 274 | /// Contents of this surface. Must be len == 0 or len == size.width * size.height 275 | buffer: []vaxis.Cell, 276 | 277 | children: []SubSurface, 278 | 279 | /// Creates a slice of vaxis.Cell's equal to size.width * size.height 280 | pub fn createBuffer(allocator: Allocator, size: Size) Allocator.Error![]vaxis.Cell { 281 | const buffer = try allocator.alloc(vaxis.Cell, size.width * size.height); 282 | @memset(buffer, .{ .default = true }); 283 | return buffer; 284 | } 285 | 286 | pub fn init(allocator: Allocator, widget: Widget, size: Size) Allocator.Error!Surface { 287 | return .{ 288 | .size = size, 289 | .widget = widget, 290 | .buffer = try Surface.createBuffer(allocator, size), 291 | .children = &.{}, 292 | }; 293 | } 294 | 295 | pub fn initWithChildren( 296 | allocator: Allocator, 297 | widget: Widget, 298 | size: Size, 299 | children: []SubSurface, 300 | ) Allocator.Error!Surface { 301 | return .{ 302 | .size = size, 303 | .widget = widget, 304 | .buffer = try Surface.createBuffer(allocator, size), 305 | .children = children, 306 | }; 307 | } 308 | 309 | pub fn writeCell(self: Surface, col: u16, row: u16, cell: vaxis.Cell) void { 310 | if (self.size.width <= col) return; 311 | if (self.size.height <= row) return; 312 | const i = (row * self.size.width) + col; 313 | assert(i < self.buffer.len); 314 | self.buffer[i] = cell; 315 | } 316 | 317 | pub fn readCell(self: Surface, col: usize, row: usize) vaxis.Cell { 318 | assert(col < self.size.width and row < self.size.height); 319 | const i = (row * self.size.width) + col; 320 | assert(i < self.buffer.len); 321 | return self.buffer[i]; 322 | } 323 | 324 | /// Creates a new surface of the same width, with the buffer trimmed to a given height 325 | pub fn trimHeight(self: Surface, height: u16) Surface { 326 | assert(height <= self.size.height); 327 | return .{ 328 | .size = .{ .width = self.size.width, .height = height }, 329 | .widget = self.widget, 330 | .buffer = self.buffer[0 .. self.size.width * height], 331 | .children = self.children, 332 | .focusable = self.focusable, 333 | .handles_mouse = self.handles_mouse, 334 | }; 335 | } 336 | 337 | /// Walks the Surface tree to produce a list of all widgets that intersect Point. Point will 338 | /// always be translated to local Surface coordinates. Asserts that this Surface does contain Point 339 | pub fn hitTest(self: Surface, list: *std.ArrayList(HitResult), point: Point) Allocator.Error!void { 340 | assert(point.col < self.size.width and point.row < self.size.height); 341 | if (self.handles_mouse) 342 | try list.append(.{ .local = point, .widget = self.widget }); 343 | for (self.children) |child| { 344 | if (!child.containsPoint(point)) continue; 345 | const child_point: Point = .{ 346 | .row = @intCast(point.row - child.origin.row), 347 | .col = @intCast(point.col - child.origin.col), 348 | }; 349 | try child.surface.hitTest(list, child_point); 350 | } 351 | } 352 | 353 | /// Copies all cells from Surface to Window 354 | pub fn render(self: Surface, win: Window, focused: Widget) void { 355 | // render self first 356 | if (self.buffer.len > 0) { 357 | assert(self.buffer.len == self.size.width * self.size.height); 358 | for (self.buffer, 0..) |cell, i| { 359 | const row = i / self.size.width; 360 | const col = i % self.size.width; 361 | win.writeCell(@intCast(col), @intCast(row), cell); 362 | } 363 | } 364 | 365 | if (self.cursor) |cursor| { 366 | if (self.widget.eql(focused)) { 367 | win.setCursor(cursor.col, cursor.row, cursor.shape); 368 | } 369 | } 370 | 371 | // Sort children by z-index 372 | std.mem.sort(SubSurface, self.children, {}, SubSurface.lessThan); 373 | 374 | // for each child, we make a window and render to it 375 | for (self.children) |child| { 376 | const child_win = win.child(child.origin.col, child.origin.row, child.surface.size); 377 | child.surface.render(child_win, focused); 378 | } 379 | } 380 | 381 | /// Returns true if the surface satisfies a set of constraints 382 | pub fn satisfiesConstraints(self: Surface, min: Size, max: Size) bool { 383 | return self.size.width < min.width and 384 | self.size.width > max.width and 385 | self.size.height < min.height and 386 | self.size.height > max.height; 387 | } 388 | }; 389 | 390 | pub const SubSurface = struct { 391 | /// Origin relative to parent 392 | origin: RelativePoint, 393 | /// This surface 394 | surface: Surface, 395 | /// z-index relative to siblings 396 | z_index: u8 = 0, 397 | 398 | pub fn lessThan(_: void, lhs: SubSurface, rhs: SubSurface) bool { 399 | return lhs.z_index < rhs.z_index; 400 | } 401 | 402 | /// Returns true if this SubSurface contains Point. Point must be in parent local units 403 | pub fn containsPoint(self: SubSurface, point: Point) bool { 404 | return point.col >= self.origin.col and 405 | point.row >= self.origin.row and 406 | point.col < (self.origin.col + self.surface.size.width) and 407 | point.row < (self.origin.row + self.surface.size.height); 408 | } 409 | }; 410 | 411 | pub const Window = struct { 412 | x_off: i32, 413 | y_off: i32, 414 | width: u16, 415 | height: u16, 416 | screen: *vaxis.Screen, 417 | 418 | pub fn writeCell(self: Window, col: u16, row: u16, cell: vaxis.Cell) void { 419 | if (self.height <= row or self.width <= col) return; 420 | if (self.x_off + col < 0) return; 421 | if (self.y_off + row < 0) return; 422 | self.screen.writeCell(@intCast(col + self.x_off), @intCast(row + self.y_off), cell); 423 | } 424 | 425 | pub fn child(self: Window, x_off: i32, y_off: i32, size: Size) Window { 426 | return .{ 427 | .x_off = self.x_off + x_off, 428 | .y_off = self.y_off + y_off, 429 | .width = size.width, 430 | .height = size.height, 431 | .screen = self.screen, 432 | }; 433 | } 434 | 435 | pub fn setCursor(self: Window, col: u16, row: u16, shape: vaxis.Cell.CursorShape) void { 436 | if (self.x_off + col < 0) return; 437 | if (self.y_off + row < 0) return; 438 | self.screen.cursor_shape = shape; 439 | self.screen.cursor_vis = true; 440 | self.screen.cursor_col = @intCast(col + self.x_off); 441 | self.screen.cursor_row = @intCast(row + self.y_off); 442 | } 443 | }; 444 | 445 | /// A noop event handler for widgets which don't require any event handling 446 | pub fn noopEventHandler(_: *anyopaque, _: *EventContext, _: Event) anyerror!void {} 447 | 448 | test { 449 | std.testing.refAllDecls(@This()); 450 | } 451 | 452 | test "SubSurface: containsPoint" { 453 | const surf: SubSurface = .{ 454 | .origin = .{ .row = 2, .col = 2 }, 455 | .surface = .{ 456 | .size = .{ .width = 10, .height = 10 }, 457 | .widget = undefined, 458 | .children = &.{}, 459 | .buffer = &.{}, 460 | }, 461 | .z_index = 0, 462 | }; 463 | 464 | try testing.expect(surf.containsPoint(.{ .row = 2, .col = 2 })); 465 | try testing.expect(surf.containsPoint(.{ .row = 3, .col = 3 })); 466 | try testing.expect(surf.containsPoint(.{ .row = 11, .col = 11 })); 467 | 468 | try testing.expect(!surf.containsPoint(.{ .row = 1, .col = 1 })); 469 | try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 12 })); 470 | try testing.expect(!surf.containsPoint(.{ .row = 2, .col = 12 })); 471 | try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 2 })); 472 | } 473 | 474 | test "All widgets have a doctest and refAllDecls test" { 475 | // This test goes through every file in src/ and checks that it has a doctest (the filename 476 | // stripped of ".zig" matches a test name) and a test called "refAllDecls". It makes no 477 | // guarantees about the quality of the test, but it does ensure it exists which at least makes 478 | // it easy to fail CI early, or spot bad tests vs non-existant tests 479 | const excludes = &[_][]const u8{ "main.zig", "App.zig" }; 480 | 481 | var cwd = try std.fs.cwd().openDir("./src", .{ .iterate = true }); 482 | var iter = cwd.iterate(); 483 | defer cwd.close(); 484 | outer: while (try iter.next()) |file| { 485 | if (file.kind != .file) continue; 486 | for (excludes) |ex| if (std.mem.eql(u8, ex, file.name)) continue :outer; 487 | 488 | const container_name = if (std.mem.lastIndexOf(u8, file.name, ".zig")) |idx| 489 | file.name[0..idx] 490 | else 491 | continue; 492 | const data = try cwd.readFileAllocOptions(std.testing.allocator, file.name, 10_000_000, null, @alignOf(u8), 0x00); 493 | defer std.testing.allocator.free(data); 494 | var ast = try std.zig.Ast.parse(std.testing.allocator, data, .zig); 495 | defer ast.deinit(std.testing.allocator); 496 | 497 | var has_doctest: bool = false; 498 | var has_refAllDecls: bool = false; 499 | for (ast.rootDecls()) |root_decl| { 500 | const decl = ast.nodes.get(root_decl); 501 | switch (decl.tag) { 502 | .test_decl => { 503 | const test_name = ast.tokenSlice(decl.data.lhs); 504 | if (std.mem.eql(u8, "\"refAllDecls\"", test_name)) 505 | has_refAllDecls = true 506 | else if (std.mem.eql(u8, container_name, test_name)) 507 | has_doctest = true; 508 | }, 509 | else => continue, 510 | } 511 | } 512 | if (!has_doctest) { 513 | std.log.err("file {s} has no doctest", .{file.name}); 514 | return error.TestExpectedDoctest; 515 | } 516 | if (!has_refAllDecls) { 517 | std.log.err("file {s} has no 'refAllDecls' test", .{file.name}); 518 | return error.TestExpectedRefAllDecls; 519 | } 520 | } 521 | } 522 | -------------------------------------------------------------------------------- /src/Text.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const Allocator = std.mem.Allocator; 5 | 6 | const vtk = @import("main.zig"); 7 | 8 | const Text = @This(); 9 | 10 | text: []const u8, 11 | style: vaxis.Style = .{}, 12 | text_align: enum { left, center, right } = .left, 13 | softwrap: bool = true, 14 | overflow: enum { ellipsis, clip } = .ellipsis, 15 | width_basis: enum { parent, longest_line } = .longest_line, 16 | 17 | pub fn widget(self: *const Text) vtk.Widget { 18 | return .{ 19 | .userdata = @constCast(self), 20 | .eventHandler = vtk.noopEventHandler, 21 | .drawFn = typeErasedDrawFn, 22 | }; 23 | } 24 | 25 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 26 | const self: *const Text = @ptrCast(@alignCast(ptr)); 27 | return self.draw(ctx); 28 | } 29 | 30 | pub fn draw(self: *const Text, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 31 | const container_size = self.findContainerSize(ctx); 32 | 33 | // Create a surface of target width and max height. We'll trim the result after drawing 34 | const surface = try vtk.Surface.init( 35 | ctx.arena, 36 | self.widget(), 37 | container_size, 38 | ); 39 | const base_style: vaxis.Style = .{ 40 | .fg = self.style.fg, 41 | .bg = self.style.bg, 42 | .reverse = self.style.reverse, 43 | }; 44 | const base: vaxis.Cell = .{ .style = base_style }; 45 | @memset(surface.buffer, base); 46 | 47 | var row: u16 = 0; 48 | if (self.softwrap) { 49 | var iter = SoftwrapIterator.init(self.text, ctx); 50 | while (iter.next()) |line| { 51 | if (row >= container_size.height) break; 52 | defer row += 1; 53 | var col: u16 = switch (self.text_align) { 54 | .left => 0, 55 | .center => (container_size.width - line.width) / 2, 56 | .right => container_size.width - line.width, 57 | }; 58 | var char_iter = ctx.graphemeIterator(line.bytes); 59 | while (char_iter.next()) |char| { 60 | const grapheme = char.bytes(line.bytes); 61 | const grapheme_width: u8 = @intCast(ctx.stringWidth(grapheme)); 62 | surface.writeCell(col, row, .{ 63 | .char = .{ .grapheme = grapheme, .width = grapheme_width }, 64 | .style = self.style, 65 | }); 66 | col += grapheme_width; 67 | } 68 | } 69 | } else { 70 | var line_iter: LineIterator = .{ .buf = self.text }; 71 | while (line_iter.next()) |line| { 72 | if (row >= container_size.height) break; 73 | const line_width = ctx.stringWidth(line); 74 | defer row += 1; 75 | const resolved_line_width = @min(container_size.width, line_width); 76 | var col: u16 = switch (self.text_align) { 77 | .left => 0, 78 | .center => (container_size.width - resolved_line_width) / 2, 79 | .right => container_size.width - resolved_line_width, 80 | }; 81 | var char_iter = ctx.graphemeIterator(line); 82 | while (char_iter.next()) |char| { 83 | if (col >= container_size.width) break; 84 | const grapheme = char.bytes(line); 85 | const grapheme_width: u8 = @intCast(ctx.stringWidth(grapheme)); 86 | 87 | if (col + grapheme_width >= container_size.width and 88 | line_width > container_size.width and 89 | self.overflow == .ellipsis) 90 | { 91 | surface.writeCell(col, row, .{ 92 | .char = .{ .grapheme = "…", .width = 1 }, 93 | .style = self.style, 94 | }); 95 | col = container_size.width; 96 | } else { 97 | surface.writeCell(col, row, .{ 98 | .char = .{ .grapheme = grapheme, .width = grapheme_width }, 99 | .style = self.style, 100 | }); 101 | col += @intCast(grapheme_width); 102 | } 103 | } 104 | } 105 | } 106 | return surface.trimHeight(@max(row, ctx.min.height)); 107 | } 108 | 109 | /// Determines the container size by finding the widest line in the viewable area 110 | fn findContainerSize(self: Text, ctx: vtk.DrawContext) vtk.Size { 111 | var row: u16 = 0; 112 | var max_width: u16 = ctx.min.width; 113 | if (self.softwrap) { 114 | var iter = SoftwrapIterator.init(self.text, ctx); 115 | while (iter.next()) |line| { 116 | if (ctx.max.outsideHeight(row)) 117 | break; 118 | 119 | defer row += 1; 120 | max_width = @max(max_width, line.width); 121 | } 122 | } else { 123 | var line_iter: LineIterator = .{ .buf = self.text }; 124 | while (line_iter.next()) |line| { 125 | if (ctx.max.outsideHeight(row)) 126 | break; 127 | const line_width: u16 = @truncate(ctx.stringWidth(line)); 128 | defer row += 1; 129 | const resolved_line_width = if (ctx.max.width) |max| 130 | @min(max, line_width) 131 | else 132 | line_width; 133 | max_width = @max(max_width, resolved_line_width); 134 | } 135 | } 136 | const result_width = switch (self.width_basis) { 137 | .longest_line => blk: { 138 | if (ctx.max.width) |max| 139 | break :blk @min(max, max_width) 140 | else 141 | break :blk max_width; 142 | }, 143 | .parent => blk: { 144 | std.debug.assert(ctx.max.width != null); 145 | break :blk ctx.max.width.?; 146 | }, 147 | }; 148 | return .{ .width = result_width, .height = @max(row, ctx.min.height) }; 149 | } 150 | 151 | /// Iterates a slice of bytes by linebreaks. Lines are split by '\r', '\n', or '\r\n' 152 | pub const LineIterator = struct { 153 | buf: []const u8, 154 | index: usize = 0, 155 | 156 | fn next(self: *LineIterator) ?[]const u8 { 157 | if (self.index >= self.buf.len) return null; 158 | 159 | const start = self.index; 160 | const end = std.mem.indexOfAnyPos(u8, self.buf, self.index, "\r\n") orelse { 161 | self.index = self.buf.len; 162 | return self.buf[start..]; 163 | }; 164 | 165 | self.index = end; 166 | self.consumeCR(); 167 | self.consumeLF(); 168 | return self.buf[start..end]; 169 | } 170 | 171 | // consumes a \n byte 172 | fn consumeLF(self: *LineIterator) void { 173 | if (self.index >= self.buf.len) return; 174 | if (self.buf[self.index] == '\n') self.index += 1; 175 | } 176 | 177 | // consumes a \r byte 178 | fn consumeCR(self: *LineIterator) void { 179 | if (self.index >= self.buf.len) return; 180 | if (self.buf[self.index] == '\r') self.index += 1; 181 | } 182 | }; 183 | 184 | pub const SoftwrapIterator = struct { 185 | ctx: vtk.DrawContext, 186 | line: []const u8 = "", 187 | index: usize = 0, 188 | hard_iter: LineIterator, 189 | 190 | pub const Line = struct { 191 | width: u16, 192 | bytes: []const u8, 193 | }; 194 | 195 | const soft_breaks = " \t"; 196 | 197 | fn init(buf: []const u8, ctx: vtk.DrawContext) SoftwrapIterator { 198 | return .{ 199 | .ctx = ctx, 200 | .hard_iter = .{ .buf = buf }, 201 | }; 202 | } 203 | 204 | fn next(self: *SoftwrapIterator) ?Line { 205 | // Advance the hard iterator 206 | if (self.index == self.line.len) { 207 | self.line = self.hard_iter.next() orelse return null; 208 | self.line = std.mem.trimRight(u8, self.line, " \t"); 209 | self.index = 0; 210 | } 211 | 212 | const start = self.index; 213 | var cur_width: u16 = 0; 214 | while (self.index < self.line.len) { 215 | const idx = self.nextWrap(); 216 | const word = self.line[self.index..idx]; 217 | const next_width = self.ctx.stringWidth(word); 218 | 219 | if (self.ctx.max.width) |max| { 220 | if (cur_width + next_width > max) { 221 | // Trim the word to see if it can fit on a line by itself 222 | const trimmed = std.mem.trimLeft(u8, word, " \t"); 223 | const trimmed_bytes = word.len - trimmed.len; 224 | // The number of bytes we trimmed is equal to the reduction in length 225 | const trimmed_width = next_width - trimmed_bytes; 226 | if (trimmed_width > max) { 227 | // Won't fit on line by itself, so fit as much on this line as we can 228 | var iter = self.ctx.graphemeIterator(word); 229 | while (iter.next()) |item| { 230 | const grapheme = item.bytes(word); 231 | const w = self.ctx.stringWidth(grapheme); 232 | if (cur_width + w > max) { 233 | const end = self.index; 234 | return .{ .width = cur_width, .bytes = self.line[start..end] }; 235 | } 236 | cur_width += @intCast(w); 237 | self.index += grapheme.len; 238 | } 239 | } 240 | // We are softwrapping, advance index to the start of the next word 241 | const end = self.index; 242 | self.index = std.mem.indexOfNonePos(u8, self.line, self.index, soft_breaks) orelse self.line.len; 243 | return .{ .width = cur_width, .bytes = self.line[start..end] }; 244 | } 245 | } 246 | 247 | self.index = idx; 248 | cur_width += @intCast(next_width); 249 | } 250 | return .{ .width = cur_width, .bytes = self.line[start..] }; 251 | } 252 | 253 | /// Determines the index of the end of the next word 254 | fn nextWrap(self: *SoftwrapIterator) usize { 255 | // Find the first linear whitespace char 256 | const start_pos = std.mem.indexOfNonePos(u8, self.line, self.index, soft_breaks) orelse 257 | return self.line.len; 258 | if (std.mem.indexOfAnyPos(u8, self.line, start_pos, soft_breaks)) |idx| { 259 | return idx; 260 | } 261 | return self.line.len; 262 | } 263 | 264 | // consumes a \n byte 265 | fn consumeLF(self: *SoftwrapIterator) void { 266 | if (self.index >= self.buf.len) return; 267 | if (self.buf[self.index] == '\n') self.index += 1; 268 | } 269 | 270 | // consumes a \r byte 271 | fn consumeCR(self: *SoftwrapIterator) void { 272 | if (self.index >= self.buf.len) return; 273 | if (self.buf[self.index] == '\r') self.index += 1; 274 | } 275 | }; 276 | 277 | test "SoftwrapIterator: LF breaks" { 278 | const unicode = try vaxis.Unicode.init(std.testing.allocator); 279 | defer unicode.deinit(); 280 | vtk.DrawContext.init(&unicode, .unicode); 281 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 282 | defer arena.deinit(); 283 | 284 | const ctx: vtk.DrawContext = .{ 285 | .min = .{ .width = 0, .height = 0 }, 286 | .max = .{ .width = 20, .height = 10 }, 287 | .arena = arena.allocator(), 288 | }; 289 | var iter = SoftwrapIterator.init("Hello, \n world", ctx); 290 | const first = iter.next(); 291 | try std.testing.expect(first != null); 292 | try std.testing.expectEqualStrings("Hello,", first.?.bytes); 293 | try std.testing.expectEqual(6, first.?.width); 294 | 295 | const second = iter.next(); 296 | try std.testing.expect(second != null); 297 | try std.testing.expectEqualStrings(" world", second.?.bytes); 298 | try std.testing.expectEqual(6, second.?.width); 299 | 300 | const end = iter.next(); 301 | try std.testing.expect(end == null); 302 | } 303 | 304 | test "SoftwrapIterator: soft breaks that fit" { 305 | const unicode = try vaxis.Unicode.init(std.testing.allocator); 306 | defer unicode.deinit(); 307 | vtk.DrawContext.init(&unicode, .unicode); 308 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 309 | defer arena.deinit(); 310 | 311 | const ctx: vtk.DrawContext = .{ 312 | .min = .{ .width = 0, .height = 0 }, 313 | .max = .{ .width = 6, .height = 10 }, 314 | .arena = arena.allocator(), 315 | }; 316 | var iter = SoftwrapIterator.init("Hello, \nworld", ctx); 317 | const first = iter.next(); 318 | try std.testing.expect(first != null); 319 | try std.testing.expectEqualStrings("Hello,", first.?.bytes); 320 | try std.testing.expectEqual(6, first.?.width); 321 | 322 | const second = iter.next(); 323 | try std.testing.expect(second != null); 324 | try std.testing.expectEqualStrings("world", second.?.bytes); 325 | try std.testing.expectEqual(5, second.?.width); 326 | 327 | const end = iter.next(); 328 | try std.testing.expect(end == null); 329 | } 330 | 331 | test "SoftwrapIterator: soft breaks that are longer than width" { 332 | const unicode = try vaxis.Unicode.init(std.testing.allocator); 333 | defer unicode.deinit(); 334 | vtk.DrawContext.init(&unicode, .unicode); 335 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 336 | defer arena.deinit(); 337 | 338 | const ctx: vtk.DrawContext = .{ 339 | .min = .{ .width = 0, .height = 0 }, 340 | .max = .{ .width = 6, .height = 10 }, 341 | .arena = arena.allocator(), 342 | }; 343 | var iter = SoftwrapIterator.init("very-long-word \nworld", ctx); 344 | const first = iter.next(); 345 | try std.testing.expect(first != null); 346 | try std.testing.expectEqualStrings("very-l", first.?.bytes); 347 | try std.testing.expectEqual(6, first.?.width); 348 | 349 | const second = iter.next(); 350 | try std.testing.expect(second != null); 351 | try std.testing.expectEqualStrings("ong-wo", second.?.bytes); 352 | try std.testing.expectEqual(6, second.?.width); 353 | 354 | const third = iter.next(); 355 | try std.testing.expect(third != null); 356 | try std.testing.expectEqualStrings("rd", third.?.bytes); 357 | try std.testing.expectEqual(2, third.?.width); 358 | 359 | const fourth = iter.next(); 360 | try std.testing.expect(fourth != null); 361 | try std.testing.expectEqualStrings("world", fourth.?.bytes); 362 | try std.testing.expectEqual(5, fourth.?.width); 363 | 364 | const end = iter.next(); 365 | try std.testing.expect(end == null); 366 | } 367 | 368 | test "SoftwrapIterator: soft breaks with leading spaces" { 369 | const unicode = try vaxis.Unicode.init(std.testing.allocator); 370 | defer unicode.deinit(); 371 | vtk.DrawContext.init(&unicode, .unicode); 372 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 373 | defer arena.deinit(); 374 | 375 | const ctx: vtk.DrawContext = .{ 376 | .min = .{ .width = 0, .height = 0 }, 377 | .max = .{ .width = 6, .height = 10 }, 378 | .arena = arena.allocator(), 379 | }; 380 | var iter = SoftwrapIterator.init("Hello, \n world", ctx); 381 | const first = iter.next(); 382 | try std.testing.expect(first != null); 383 | try std.testing.expectEqualStrings("Hello,", first.?.bytes); 384 | try std.testing.expectEqual(6, first.?.width); 385 | 386 | const second = iter.next(); 387 | try std.testing.expect(second != null); 388 | try std.testing.expectEqualStrings(" world", second.?.bytes); 389 | try std.testing.expectEqual(6, second.?.width); 390 | 391 | const end = iter.next(); 392 | try std.testing.expect(end == null); 393 | } 394 | 395 | test "LineIterator: LF breaks" { 396 | const input = "Hello, \n world"; 397 | var iter: LineIterator = .{ .buf = input }; 398 | const first = iter.next(); 399 | try std.testing.expect(first != null); 400 | try std.testing.expectEqualStrings("Hello, ", first.?); 401 | 402 | const second = iter.next(); 403 | try std.testing.expect(second != null); 404 | try std.testing.expectEqualStrings(" world", second.?); 405 | 406 | const end = iter.next(); 407 | try std.testing.expect(end == null); 408 | } 409 | 410 | test "LineIterator: CR breaks" { 411 | const input = "Hello, \r world"; 412 | var iter: LineIterator = .{ .buf = input }; 413 | const first = iter.next(); 414 | try std.testing.expect(first != null); 415 | try std.testing.expectEqualStrings("Hello, ", first.?); 416 | 417 | const second = iter.next(); 418 | try std.testing.expect(second != null); 419 | try std.testing.expectEqualStrings(" world", second.?); 420 | 421 | const end = iter.next(); 422 | try std.testing.expect(end == null); 423 | } 424 | 425 | test "LineIterator: CRLF breaks" { 426 | const input = "Hello, \r\n world"; 427 | var iter: LineIterator = .{ .buf = input }; 428 | const first = iter.next(); 429 | try std.testing.expect(first != null); 430 | try std.testing.expectEqualStrings("Hello, ", first.?); 431 | 432 | const second = iter.next(); 433 | try std.testing.expect(second != null); 434 | try std.testing.expectEqualStrings(" world", second.?); 435 | 436 | const end = iter.next(); 437 | try std.testing.expect(end == null); 438 | } 439 | 440 | test "LineIterator: CRLF breaks with empty line" { 441 | const input = "Hello, \r\n\r\n world"; 442 | var iter: LineIterator = .{ .buf = input }; 443 | const first = iter.next(); 444 | try std.testing.expect(first != null); 445 | try std.testing.expectEqualStrings("Hello, ", first.?); 446 | 447 | const second = iter.next(); 448 | try std.testing.expect(second != null); 449 | try std.testing.expectEqualStrings("", second.?); 450 | 451 | const third = iter.next(); 452 | try std.testing.expect(third != null); 453 | try std.testing.expectEqualStrings(" world", third.?); 454 | 455 | const end = iter.next(); 456 | try std.testing.expect(end == null); 457 | } 458 | 459 | test Text { 460 | var text: Text = .{ .text = "Hello, world" }; 461 | const text_widget = text.widget(); 462 | 463 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 464 | defer arena.deinit(); 465 | const ucd = try vaxis.Unicode.init(arena.allocator()); 466 | vtk.DrawContext.init(&ucd, .unicode); 467 | 468 | // Center expands to the max size. It must therefore have non-null max width and max height. 469 | // These values are asserted in draw 470 | const ctx: vtk.DrawContext = .{ 471 | .arena = arena.allocator(), 472 | .min = .{}, 473 | .max = .{ .width = 7, .height = 2 }, 474 | }; 475 | 476 | { 477 | // Text softwraps by default 478 | const surface = try text_widget.draw(ctx); 479 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 6, .height = 2 }), surface.size); 480 | } 481 | 482 | { 483 | text.softwrap = false; 484 | text.overflow = .ellipsis; 485 | const surface = try text_widget.draw(ctx); 486 | try std.testing.expectEqual(@as(vtk.Size, .{ .width = 7, .height = 1 }), surface.size); 487 | // The last character will be an ellipsis 488 | try std.testing.expectEqualStrings("…", surface.buffer[surface.buffer.len - 1].char.grapheme); 489 | } 490 | } 491 | 492 | test "refAllDecls" { 493 | std.testing.refAllDecls(@This()); 494 | } 495 | -------------------------------------------------------------------------------- /src/TextField.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const vtk = @import("main.zig"); 5 | 6 | const assert = std.debug.assert; 7 | 8 | const Allocator = std.mem.Allocator; 9 | const Key = vaxis.Key; 10 | const Cell = vaxis.Cell; 11 | const Window = vaxis.Window; 12 | const Unicode = vaxis.Unicode; 13 | 14 | const TextField = @This(); 15 | 16 | const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 }; 17 | 18 | // Index of our cursor 19 | buf: Buffer, 20 | 21 | /// the number of graphemes to skip when drawing. Used for horizontal scrolling 22 | draw_offset: u16 = 0, 23 | /// the column we placed the cursor the last time we drew 24 | prev_cursor_col: u16 = 0, 25 | /// the grapheme index of the cursor the last time we drew 26 | prev_cursor_idx: u16 = 0, 27 | /// approximate distance from an edge before we scroll 28 | scroll_offset: u4 = 4, 29 | /// Previous width we drew at 30 | prev_width: u16 = 0, 31 | 32 | unicode: *const Unicode, 33 | 34 | previous_val: []const u8 = "", 35 | 36 | userdata: ?*anyopaque = null, 37 | onChange: ?*const fn (?*anyopaque, *vtk.EventContext, []const u8) anyerror!void = null, 38 | onSubmit: ?*const fn (?*anyopaque, *vtk.EventContext, []const u8) anyerror!void = null, 39 | 40 | pub fn init(alloc: std.mem.Allocator, unicode: *const Unicode) TextField { 41 | return TextField{ 42 | .buf = Buffer.init(alloc), 43 | .unicode = unicode, 44 | }; 45 | } 46 | 47 | pub fn deinit(self: *TextField) void { 48 | self.buf.allocator.free(self.previous_val); 49 | self.buf.deinit(); 50 | } 51 | 52 | pub fn widget(self: *TextField) vtk.Widget { 53 | return .{ 54 | .userdata = self, 55 | .eventHandler = typeErasedEventHandler, 56 | .drawFn = typeErasedDrawFn, 57 | }; 58 | } 59 | 60 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 61 | const self: *TextField = @ptrCast(@alignCast(ptr)); 62 | return self.handleEvent(ctx, event); 63 | } 64 | 65 | pub fn handleEvent(self: *TextField, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 66 | switch (event) { 67 | .focus_out, .focus_in => ctx.redraw = true, 68 | .key_press => |key| { 69 | if (key.matches(Key.backspace, .{})) { 70 | self.deleteBeforeCursor(); 71 | return self.checkChanged(ctx); 72 | } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) { 73 | self.deleteAfterCursor(); 74 | return self.checkChanged(ctx); 75 | } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) { 76 | self.cursorLeft(); 77 | return ctx.consumeAndRedraw(); 78 | } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) { 79 | self.cursorRight(); 80 | return ctx.consumeAndRedraw(); 81 | } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) { 82 | self.buf.moveGapLeft(self.buf.firstHalf().len); 83 | return ctx.consumeAndRedraw(); 84 | } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) { 85 | self.buf.moveGapRight(self.buf.secondHalf().len); 86 | return ctx.consumeAndRedraw(); 87 | } else if (key.matches('k', .{ .ctrl = true })) { 88 | self.deleteToEnd(); 89 | return self.checkChanged(ctx); 90 | } else if (key.matches('u', .{ .ctrl = true })) { 91 | self.deleteToStart(); 92 | return self.checkChanged(ctx); 93 | } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) { 94 | self.moveBackwardWordwise(); 95 | return ctx.consumeAndRedraw(); 96 | } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) { 97 | self.moveForwardWordwise(); 98 | return ctx.consumeAndRedraw(); 99 | } else if (key.matches('w', .{ .ctrl = true }) or key.matches(Key.backspace, .{ .alt = true })) { 100 | self.deleteWordBefore(); 101 | return self.checkChanged(ctx); 102 | } else if (key.matches('d', .{ .alt = true })) { 103 | self.deleteWordAfter(); 104 | return self.checkChanged(ctx); 105 | } else if (key.matches(vaxis.Key.enter, .{})) { 106 | if (self.onSubmit) |onSubmit| { 107 | try onSubmit(self.userdata, ctx, self.previous_val); 108 | return ctx.consumeAndRedraw(); 109 | } 110 | } else if (key.text) |text| { 111 | try self.insertSliceAtCursor(text); 112 | return self.checkChanged(ctx); 113 | } 114 | }, 115 | else => {}, 116 | } 117 | } 118 | 119 | fn checkChanged(self: *TextField, ctx: *vtk.EventContext) anyerror!void { 120 | const new = try self.buf.dupe(); 121 | if (std.mem.eql(u8, new, self.previous_val)) { 122 | self.buf.allocator.free(new); 123 | return ctx.consumeAndRedraw(); 124 | } 125 | self.buf.allocator.free(self.previous_val); 126 | self.previous_val = new; 127 | if (self.onChange) |onChange| { 128 | try onChange(self.userdata, ctx, new); 129 | } 130 | ctx.consumeAndRedraw(); 131 | } 132 | 133 | /// insert text at the cursor position 134 | pub fn insertSliceAtCursor(self: *TextField, data: []const u8) std.mem.Allocator.Error!void { 135 | var iter = self.unicode.graphemeIterator(data); 136 | while (iter.next()) |text| { 137 | try self.buf.insertSliceAtCursor(text.bytes(data)); 138 | } 139 | } 140 | 141 | pub fn sliceToCursor(self: *TextField, buf: []u8) []const u8 { 142 | assert(buf.len >= self.buf.cursor); 143 | @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf()); 144 | return buf[0..self.buf.cursor]; 145 | } 146 | 147 | /// calculates the display width from the draw_offset to the cursor 148 | pub fn widthToCursor(self: *TextField, ctx: vtk.DrawContext) u16 { 149 | var width: u16 = 0; 150 | const first_half = self.buf.firstHalf(); 151 | var first_iter = self.unicode.graphemeIterator(first_half); 152 | var i: usize = 0; 153 | while (first_iter.next()) |grapheme| { 154 | defer i += 1; 155 | if (i < self.draw_offset) { 156 | continue; 157 | } 158 | const g = grapheme.bytes(first_half); 159 | width += @intCast(ctx.stringWidth(g)); 160 | } 161 | return width; 162 | } 163 | 164 | pub fn cursorLeft(self: *TextField) void { 165 | // We need to find the size of the last grapheme in the first half 166 | var iter = self.unicode.graphemeIterator(self.buf.firstHalf()); 167 | var len: usize = 0; 168 | while (iter.next()) |grapheme| { 169 | len = grapheme.len; 170 | } 171 | self.buf.moveGapLeft(len); 172 | } 173 | 174 | pub fn cursorRight(self: *TextField) void { 175 | var iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 176 | const grapheme = iter.next() orelse return; 177 | self.buf.moveGapRight(grapheme.len); 178 | } 179 | 180 | pub fn graphemesBeforeCursor(self: *const TextField) u16 { 181 | const first_half = self.buf.firstHalf(); 182 | var first_iter = self.unicode.graphemeIterator(first_half); 183 | var i: u16 = 0; 184 | while (first_iter.next()) |_| { 185 | i += 1; 186 | } 187 | return i; 188 | } 189 | 190 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 191 | const self: *TextField = @ptrCast(@alignCast(ptr)); 192 | return self.draw(ctx); 193 | } 194 | 195 | pub fn draw(self: *TextField, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 196 | std.debug.assert(ctx.max.width != null); 197 | const max_width = ctx.max.width.?; 198 | if (max_width != self.prev_width) { 199 | self.prev_width = max_width; 200 | self.draw_offset = 0; 201 | self.prev_cursor_col = 0; 202 | } 203 | // Create a surface with max width and a minimum height of 1. 204 | var surface = try vtk.Surface.init( 205 | ctx.arena, 206 | self.widget(), 207 | .{ .width = max_width, .height = @max(ctx.min.height, 1) }, 208 | ); 209 | surface.focusable = true; 210 | surface.handles_mouse = true; 211 | 212 | const base: vaxis.Cell = .{ .style = .{} }; 213 | @memset(surface.buffer, base); 214 | const style: vaxis.Style = .{}; 215 | const cursor_idx = self.graphemesBeforeCursor(); 216 | if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx; 217 | if (max_width == 0) return surface; 218 | while (true) { 219 | const width = self.widthToCursor(ctx); 220 | if (width >= max_width) { 221 | self.draw_offset +|= width - max_width + 1; 222 | continue; 223 | } else break; 224 | } 225 | 226 | self.prev_cursor_idx = cursor_idx; 227 | self.prev_cursor_col = 0; 228 | 229 | const first_half = self.buf.firstHalf(); 230 | var first_iter = self.unicode.graphemeIterator(first_half); 231 | var col: u16 = 0; 232 | var i: u16 = 0; 233 | while (first_iter.next()) |grapheme| { 234 | if (i < self.draw_offset) { 235 | i += 1; 236 | continue; 237 | } 238 | const g = grapheme.bytes(first_half); 239 | const w: u8 = @intCast(ctx.stringWidth(g)); 240 | if (col + w >= max_width) { 241 | surface.writeCell(max_width - 1, 0, .{ 242 | .char = ellipsis, 243 | .style = style, 244 | }); 245 | break; 246 | } 247 | surface.writeCell(@intCast(col), 0, .{ 248 | .char = .{ 249 | .grapheme = g, 250 | .width = w, 251 | }, 252 | .style = style, 253 | }); 254 | col += w; 255 | i += 1; 256 | if (i == cursor_idx) self.prev_cursor_col = col; 257 | } 258 | const second_half = self.buf.secondHalf(); 259 | var second_iter = self.unicode.graphemeIterator(second_half); 260 | while (second_iter.next()) |grapheme| { 261 | if (i < self.draw_offset) { 262 | i += 1; 263 | continue; 264 | } 265 | const g = grapheme.bytes(second_half); 266 | const w: u8 = @intCast(ctx.stringWidth(g)); 267 | if (col + w > max_width) { 268 | surface.writeCell(max_width - 1, 0, .{ 269 | .char = ellipsis, 270 | .style = style, 271 | }); 272 | break; 273 | } 274 | surface.writeCell(@intCast(col), 0, .{ 275 | .char = .{ 276 | .grapheme = g, 277 | .width = w, 278 | }, 279 | .style = style, 280 | }); 281 | col += w; 282 | i += 1; 283 | if (i == cursor_idx) self.prev_cursor_col = col; 284 | } 285 | if (self.draw_offset > 0) { 286 | surface.writeCell(0, 0, .{ 287 | .char = ellipsis, 288 | .style = style, 289 | }); 290 | } 291 | surface.cursor = .{ .col = @intCast(self.prev_cursor_col), .row = 0 }; 292 | return surface; 293 | // win.showCursor(self.prev_cursor_col, 0); 294 | } 295 | 296 | pub fn clearAndFree(self: *TextField) void { 297 | self.buf.clearAndFree(); 298 | self.reset(); 299 | } 300 | 301 | pub fn clearRetainingCapacity(self: *TextField) void { 302 | self.buf.clearRetainingCapacity(); 303 | self.reset(); 304 | } 305 | 306 | pub fn toOwnedSlice(self: *TextField) ![]const u8 { 307 | defer self.reset(); 308 | return self.buf.toOwnedSlice(); 309 | } 310 | 311 | pub fn reset(self: *TextField) void { 312 | self.draw_offset = 0; 313 | self.prev_cursor_col = 0; 314 | self.prev_cursor_idx = 0; 315 | } 316 | 317 | // returns the number of bytes before the cursor 318 | pub fn byteOffsetToCursor(self: TextField) usize { 319 | return self.buf.cursor; 320 | } 321 | 322 | pub fn deleteToEnd(self: *TextField) void { 323 | self.buf.growGapRight(self.buf.secondHalf().len); 324 | } 325 | 326 | pub fn deleteToStart(self: *TextField) void { 327 | self.buf.growGapLeft(self.buf.cursor); 328 | } 329 | 330 | pub fn deleteBeforeCursor(self: *TextField) void { 331 | // We need to find the size of the last grapheme in the first half 332 | var iter = self.unicode.graphemeIterator(self.buf.firstHalf()); 333 | var len: usize = 0; 334 | while (iter.next()) |grapheme| { 335 | len = grapheme.len; 336 | } 337 | self.buf.growGapLeft(len); 338 | } 339 | 340 | pub fn deleteAfterCursor(self: *TextField) void { 341 | var iter = self.unicode.graphemeIterator(self.buf.secondHalf()); 342 | const grapheme = iter.next() orelse return; 343 | self.buf.growGapRight(grapheme.len); 344 | } 345 | 346 | /// Moves the cursor backward by words. If the character before the cursor is a space, the cursor is 347 | /// positioned just after the next previous space 348 | pub fn moveBackwardWordwise(self: *TextField) void { 349 | const trimmed = std.mem.trimRight(u8, self.buf.firstHalf(), " "); 350 | const idx = if (std.mem.lastIndexOfScalar(u8, trimmed, ' ')) |last| 351 | last + 1 352 | else 353 | 0; 354 | self.buf.moveGapLeft(self.buf.cursor - idx); 355 | } 356 | 357 | pub fn moveForwardWordwise(self: *TextField) void { 358 | const second_half = self.buf.secondHalf(); 359 | var i: usize = 0; 360 | while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 361 | const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 362 | self.buf.moveGapRight(idx); 363 | } 364 | 365 | pub fn deleteWordBefore(self: *TextField) void { 366 | // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 367 | // moved 368 | const pre = self.buf.cursor; 369 | self.moveBackwardWordwise(); 370 | self.buf.growGapRight(pre - self.buf.cursor); 371 | } 372 | 373 | pub fn deleteWordAfter(self: *TextField) void { 374 | // Store current cursor position. Move one word backward. Delete after the cursor the bytes we 375 | // moved 376 | const second_half = self.buf.secondHalf(); 377 | var i: usize = 0; 378 | while (i < second_half.len and second_half[i] == ' ') : (i += 1) {} 379 | const idx = std.mem.indexOfScalarPos(u8, second_half, i, ' ') orelse second_half.len; 380 | self.buf.growGapRight(idx); 381 | } 382 | 383 | test "sliceToCursor" { 384 | const alloc = std.testing.allocator_instance.allocator(); 385 | const unicode = try Unicode.init(alloc); 386 | defer unicode.deinit(); 387 | var input = init(alloc, &unicode); 388 | defer input.deinit(); 389 | try input.insertSliceAtCursor("hello, world"); 390 | input.cursorLeft(); 391 | input.cursorLeft(); 392 | input.cursorLeft(); 393 | var buf: [32]u8 = undefined; 394 | try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf)); 395 | input.cursorRight(); 396 | try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf)); 397 | } 398 | 399 | pub const Buffer = struct { 400 | allocator: std.mem.Allocator, 401 | buffer: []u8, 402 | cursor: usize, 403 | gap_size: usize, 404 | 405 | pub fn init(allocator: std.mem.Allocator) Buffer { 406 | return .{ 407 | .allocator = allocator, 408 | .buffer = &.{}, 409 | .cursor = 0, 410 | .gap_size = 0, 411 | }; 412 | } 413 | 414 | pub fn deinit(self: *Buffer) void { 415 | self.allocator.free(self.buffer); 416 | } 417 | 418 | pub fn firstHalf(self: Buffer) []const u8 { 419 | return self.buffer[0..self.cursor]; 420 | } 421 | 422 | pub fn secondHalf(self: Buffer) []const u8 { 423 | return self.buffer[self.cursor + self.gap_size ..]; 424 | } 425 | 426 | pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void { 427 | // Always grow by 512 bytes 428 | const new_size = self.buffer.len + n + 512; 429 | // Allocate the new memory 430 | const new_memory = try self.allocator.alloc(u8, new_size); 431 | // Copy the first half 432 | @memcpy(new_memory[0..self.cursor], self.firstHalf()); 433 | // Copy the second half 434 | const second_half = self.secondHalf(); 435 | @memcpy(new_memory[new_size - second_half.len ..], second_half); 436 | self.allocator.free(self.buffer); 437 | self.buffer = new_memory; 438 | self.gap_size = new_size - second_half.len - self.cursor; 439 | } 440 | 441 | pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void { 442 | if (slice.len == 0) return; 443 | if (self.gap_size <= slice.len) try self.grow(slice.len); 444 | @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice); 445 | self.cursor += slice.len; 446 | self.gap_size -= slice.len; 447 | } 448 | 449 | /// Move the gap n bytes to the left 450 | pub fn moveGapLeft(self: *Buffer, n: usize) void { 451 | const new_idx = self.cursor -| n; 452 | const dst = self.buffer[new_idx + self.gap_size ..]; 453 | const src = self.buffer[new_idx..self.cursor]; 454 | std.mem.copyForwards(u8, dst, src); 455 | self.cursor = new_idx; 456 | } 457 | 458 | pub fn moveGapRight(self: *Buffer, n: usize) void { 459 | const new_idx = self.cursor + n; 460 | const dst = self.buffer[self.cursor..]; 461 | const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size]; 462 | std.mem.copyForwards(u8, dst, src); 463 | self.cursor = new_idx; 464 | } 465 | 466 | /// grow the gap by moving the cursor n bytes to the left 467 | pub fn growGapLeft(self: *Buffer, n: usize) void { 468 | // gap grows by the delta 469 | self.gap_size += n; 470 | self.cursor -|= n; 471 | } 472 | 473 | /// grow the gap by removing n bytes after the cursor 474 | pub fn growGapRight(self: *Buffer, n: usize) void { 475 | self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor); 476 | } 477 | 478 | pub fn clearAndFree(self: *Buffer) void { 479 | self.cursor = 0; 480 | self.allocator.free(self.buffer); 481 | self.buffer = &.{}; 482 | self.gap_size = 0; 483 | } 484 | 485 | pub fn clearRetainingCapacity(self: *Buffer) void { 486 | self.cursor = 0; 487 | self.gap_size = self.buffer.len; 488 | } 489 | 490 | pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 { 491 | const slice = try self.dupe(); 492 | self.clearAndFree(); 493 | return slice; 494 | } 495 | 496 | pub fn realLength(self: *const Buffer) usize { 497 | return self.firstHalf().len + self.secondHalf().len; 498 | } 499 | 500 | pub fn dupe(self: *const Buffer) std.mem.Allocator.Error![]const u8 { 501 | const first_half = self.firstHalf(); 502 | const second_half = self.secondHalf(); 503 | const buf = try self.allocator.alloc(u8, first_half.len + second_half.len); 504 | @memcpy(buf[0..first_half.len], first_half); 505 | @memcpy(buf[first_half.len..], second_half); 506 | return buf; 507 | } 508 | }; 509 | 510 | test "TextField.zig: Buffer" { 511 | var gap_buf = Buffer.init(std.testing.allocator); 512 | defer gap_buf.deinit(); 513 | 514 | try gap_buf.insertSliceAtCursor("abc"); 515 | try std.testing.expectEqualStrings("abc", gap_buf.firstHalf()); 516 | try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 517 | 518 | gap_buf.moveGapLeft(1); 519 | try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 520 | try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 521 | 522 | try gap_buf.insertSliceAtCursor(" "); 523 | try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf()); 524 | try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 525 | 526 | gap_buf.growGapLeft(1); 527 | try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 528 | try std.testing.expectEqualStrings("c", gap_buf.secondHalf()); 529 | try std.testing.expectEqual(2, gap_buf.cursor); 530 | 531 | gap_buf.growGapRight(1); 532 | try std.testing.expectEqualStrings("ab", gap_buf.firstHalf()); 533 | try std.testing.expectEqualStrings("", gap_buf.secondHalf()); 534 | try std.testing.expectEqual(2, gap_buf.cursor); 535 | } 536 | 537 | test TextField { 538 | // Boiler plate draw context init 539 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 540 | defer arena.deinit(); 541 | const ucd = try vaxis.Unicode.init(arena.allocator()); 542 | vtk.DrawContext.init(&ucd, .unicode); 543 | 544 | // Create some object which reacts to text field changes 545 | const Foo = struct { 546 | allocator: std.mem.Allocator, 547 | text: []const u8, 548 | 549 | fn onChange(ptr: ?*anyopaque, ctx: *vtk.EventContext, str: []const u8) anyerror!void { 550 | const foo: *@This() = @ptrCast(@alignCast(ptr)); 551 | foo.text = try foo.allocator.dupe(u8, str); 552 | ctx.consumeAndRedraw(); 553 | } 554 | }; 555 | var foo: Foo = .{ .text = "", .allocator = arena.allocator() }; 556 | 557 | // Text field expands to the width, so it can't be null. It is always 1 line tall 558 | const draw_ctx: vtk.DrawContext = .{ 559 | .arena = arena.allocator(), 560 | .min = .{}, 561 | .max = .{ .width = 8, .height = 1 }, 562 | }; 563 | _ = draw_ctx; 564 | 565 | var ctx: vtk.EventContext = .{ 566 | .cmds = vtk.CommandList.init(arena.allocator()), 567 | }; 568 | 569 | // Enough boiler plate...Create the text field 570 | var text_field = TextField.init(std.testing.allocator, &ucd); 571 | defer text_field.deinit(); 572 | text_field.onChange = Foo.onChange; 573 | text_field.onSubmit = Foo.onChange; 574 | text_field.userdata = &foo; 575 | 576 | const tf_widget = text_field.widget(); 577 | // Send some key events to the widget 578 | try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'H', .text = "H" } }); 579 | // The foo object stores the last text that we saw from an onChange call 580 | try std.testing.expectEqualStrings("H", foo.text); 581 | try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'e', .text = "e" } }); 582 | try std.testing.expectEqualStrings("He", foo.text); 583 | try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } }); 584 | try std.testing.expectEqualStrings("Hel", foo.text); 585 | try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } }); 586 | try std.testing.expectEqualStrings("Hell", foo.text); 587 | try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'o', .text = "o" } }); 588 | try std.testing.expectEqualStrings("Hello", foo.text); 589 | 590 | // An arrow moves the cursor. The text doesn't change 591 | try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.left } }); 592 | try std.testing.expectEqualStrings("Hello", foo.text); 593 | 594 | try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = '_', .text = "_" } }); 595 | try std.testing.expectEqualStrings("Hell_o", foo.text); 596 | } 597 | 598 | test "refAllDecls" { 599 | std.testing.refAllDecls(@This()); 600 | } 601 | -------------------------------------------------------------------------------- /src/ListView.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const vaxis = @import("vaxis"); 3 | 4 | const assert = std.debug.assert; 5 | 6 | const Allocator = std.mem.Allocator; 7 | 8 | const vtk = @import("main.zig"); 9 | 10 | const ListView = @This(); 11 | 12 | pub const Builder = struct { 13 | userdata: *const anyopaque, 14 | buildFn: *const fn (*const anyopaque, idx: usize, cursor: usize) ?vtk.Widget, 15 | 16 | inline fn itemAtIdx(self: Builder, idx: usize, cursor: usize) ?vtk.Widget { 17 | return self.buildFn(self.userdata, idx, cursor); 18 | } 19 | }; 20 | 21 | pub const Source = union(enum) { 22 | slice: []const vtk.Widget, 23 | builder: Builder, 24 | }; 25 | 26 | const Scroll = struct { 27 | /// Index of the first fully-in-view widget 28 | top: u32 = 0, 29 | /// Line offset within the top widget. 30 | offset: i32 = 0, 31 | /// Pending scroll amount 32 | pending_lines: i32 = 0, 33 | /// If there is more room to scroll down 34 | has_more: bool = true, 35 | /// The cursor must be in the viewport 36 | wants_cursor: bool = false, 37 | 38 | fn linesDown(self: *Scroll, n: u8) bool { 39 | if (!self.has_more) return false; 40 | self.pending_lines += n; 41 | return true; 42 | } 43 | 44 | fn linesUp(self: *Scroll, n: u8) bool { 45 | if (self.top == 0 and self.offset == 0) return false; 46 | self.pending_lines = -1 * @as(i32, @intCast(n)); 47 | return true; 48 | } 49 | }; 50 | 51 | const cursor_indicator: vaxis.Cell = .{ .char = .{ .grapheme = "▐", .width = 1 } }; 52 | 53 | children: Source, 54 | cursor: u32 = 0, 55 | /// When true, the widget will draw a cursor next to the widget which has the cursor 56 | draw_cursor: bool = true, 57 | /// Lines to scroll for a mouse wheel 58 | wheel_scroll: u8 = 3, 59 | /// Set this if the exact item count is known. 60 | item_count: ?u32 = null, 61 | 62 | /// scroll position 63 | scroll: Scroll = .{}, 64 | 65 | pub fn widget(self: *const ListView) vtk.Widget { 66 | return .{ 67 | .userdata = @constCast(self), 68 | .eventHandler = typeErasedEventHandler, 69 | .drawFn = typeErasedDrawFn, 70 | }; 71 | } 72 | 73 | fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 74 | const self: *ListView = @ptrCast(@alignCast(ptr)); 75 | return self.handleEvent(ctx, event); 76 | } 77 | 78 | fn typeErasedDrawFn(ptr: *anyopaque, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 79 | const self: *ListView = @ptrCast(@alignCast(ptr)); 80 | return self.draw(ctx); 81 | } 82 | 83 | pub fn handleEvent(self: *ListView, ctx: *vtk.EventContext, event: vtk.Event) anyerror!void { 84 | switch (event) { 85 | .mouse => |mouse| { 86 | if (mouse.button == .wheel_up) { 87 | if (self.scroll.linesUp(self.wheel_scroll)) 88 | ctx.consumeAndRedraw(); 89 | } 90 | if (mouse.button == .wheel_down) { 91 | if (self.scroll.linesDown(self.wheel_scroll)) 92 | ctx.consumeAndRedraw(); 93 | } 94 | }, 95 | .key_press => |key| { 96 | if (key.matches('j', .{}) or 97 | key.matches('n', .{ .ctrl = true }) or 98 | key.matches(vaxis.Key.down, .{})) 99 | { 100 | return self.nextItem(ctx); 101 | } 102 | if (key.matches('k', .{}) or 103 | key.matches('p', .{ .ctrl = true }) or 104 | key.matches(vaxis.Key.up, .{})) 105 | { 106 | return self.prevItem(ctx); 107 | } 108 | if (key.matches(vaxis.Key.escape, .{})) { 109 | self.ensureScroll(); 110 | return ctx.consumeAndRedraw(); 111 | } 112 | 113 | // All other keypresses go to our focused child 114 | switch (self.children) { 115 | .slice => |slice| { 116 | const child = slice[self.cursor]; 117 | return child.handleEvent(ctx, event); 118 | }, 119 | .builder => |builder| { 120 | if (builder.itemAtIdx(self.cursor, self.cursor)) |child| { 121 | return child.handleEvent(ctx, event); 122 | } 123 | }, 124 | } 125 | }, 126 | else => {}, 127 | } 128 | } 129 | 130 | pub fn draw(self: *ListView, ctx: vtk.DrawContext) Allocator.Error!vtk.Surface { 131 | std.debug.assert(ctx.max.width != null); 132 | std.debug.assert(ctx.max.height != null); 133 | switch (self.children) { 134 | .slice => |slice| { 135 | self.item_count = @intCast(slice.len); 136 | const builder: SliceBuilder = .{ .slice = slice }; 137 | return self.drawBuilder(ctx, .{ .userdata = &builder, .buildFn = SliceBuilder.build }); 138 | }, 139 | .builder => |b| return self.drawBuilder(ctx, b), 140 | } 141 | } 142 | 143 | pub fn nextItem(self: *ListView, ctx: *vtk.EventContext) void { 144 | // If we have a count, we can handle this directly 145 | if (self.item_count) |count| { 146 | if (self.cursor >= count - 1) { 147 | return ctx.consumeEvent(); 148 | } 149 | self.cursor += 1; 150 | } else { 151 | switch (self.children) { 152 | .slice => |slice| { 153 | self.item_count = @intCast(slice.len); 154 | // If we are already at the end, don't do anything 155 | if (self.cursor == slice.len - 1) { 156 | return ctx.consumeEvent(); 157 | } 158 | // Advance the cursor 159 | self.cursor += 1; 160 | }, 161 | .builder => |builder| { 162 | // Save our current state 163 | const prev = self.cursor; 164 | // Advance the cursor 165 | self.cursor += 1; 166 | // Check the bounds, reversing until we get the last item 167 | while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 168 | self.cursor -|= 1; 169 | } 170 | // If we didn't change state, we don't redraw 171 | if (self.cursor == prev) { 172 | return ctx.consumeEvent(); 173 | } 174 | }, 175 | } 176 | } 177 | // Reset scroll 178 | self.ensureScroll(); 179 | ctx.consumeAndRedraw(); 180 | } 181 | 182 | pub fn prevItem(self: *ListView, ctx: *vtk.EventContext) void { 183 | if (self.cursor == 0) { 184 | return ctx.consumeEvent(); 185 | } 186 | 187 | if (self.item_count) |count| { 188 | // If for some reason our count changed, we handle it here 189 | self.cursor = @min(self.cursor - 1, count - 1); 190 | } else { 191 | switch (self.children) { 192 | .slice => |slice| { 193 | self.item_count = @intCast(slice.len); 194 | self.cursor = @min(self.cursor - 1, slice.len - 1); 195 | }, 196 | .builder => |builder| { 197 | // Save our current state 198 | const prev = self.cursor; 199 | // Decrement the cursor 200 | self.cursor -= 1; 201 | // Check the bounds, reversing until we get the last item 202 | while (builder.itemAtIdx(self.cursor, self.cursor) == null) { 203 | self.cursor -|= 1; 204 | } 205 | // If we didn't change state, we don't redraw 206 | if (self.cursor == prev) { 207 | return ctx.consumeEvent(); 208 | } 209 | }, 210 | } 211 | } 212 | 213 | // Reset scroll 214 | self.ensureScroll(); 215 | return ctx.consumeAndRedraw(); 216 | } 217 | 218 | // Only call when cursor state has changed, or we want to ensure the cursored item is in view 219 | pub fn ensureScroll(self: *ListView) void { 220 | if (self.cursor <= self.scroll.top) { 221 | self.scroll.top = @intCast(self.cursor); 222 | self.scroll.offset = 0; 223 | } else { 224 | self.scroll.wants_cursor = true; 225 | } 226 | } 227 | 228 | /// Inserts children until add_height is < 0 229 | fn insertChildren( 230 | self: *ListView, 231 | ctx: vtk.DrawContext, 232 | builder: Builder, 233 | child_list: *std.ArrayList(vtk.SubSurface), 234 | add_height: i32, 235 | ) Allocator.Error!void { 236 | assert(self.scroll.top > 0); 237 | self.scroll.top -= 1; 238 | var upheight = add_height; 239 | while (self.scroll.top >= 0) : (self.scroll.top -= 1) { 240 | // Get the child 241 | const child = builder.itemAtIdx(self.scroll.top, self.cursor) orelse break; 242 | 243 | const child_offset: u16 = if (self.draw_cursor) 2 else 0; 244 | const max_size = ctx.max.size(); 245 | 246 | // Set up constraints. We let the child be the entire height if it wants 247 | const child_ctx = ctx.withConstraints( 248 | .{ .width = max_size.width - child_offset, .height = 0 }, 249 | .{ .width = max_size.width - child_offset, .height = null }, 250 | ); 251 | 252 | // Draw the child 253 | const surf = try child.draw(child_ctx); 254 | 255 | // Accumulate the height. Traversing backward so do this before setting origin 256 | upheight -= surf.size.height; 257 | 258 | // Insert the child to the beginning of the list 259 | try child_list.insert(0, .{ 260 | .origin = .{ .col = 2, .row = upheight }, 261 | .surface = surf, 262 | .z_index = 0, 263 | }); 264 | 265 | // Break if we went past the top edge, or are the top item 266 | if (upheight <= 0 or self.scroll.top == 0) break; 267 | } 268 | 269 | // Our new offset is the "upheight" 270 | self.scroll.offset = upheight; 271 | 272 | // Reset origins if we overshot and put the top item too low 273 | if (self.scroll.top == 0 and upheight > 0) { 274 | self.scroll.offset = 0; 275 | var row: i32 = 0; 276 | for (child_list.items) |*child| { 277 | child.origin.row = row; 278 | row += child.surface.size.height; 279 | } 280 | } 281 | // Our new offset is the "upheight" 282 | self.scroll.offset = upheight; 283 | } 284 | 285 | fn totalHeight(list: *const std.ArrayList(vtk.SubSurface)) usize { 286 | var result: usize = 0; 287 | for (list.items) |child| { 288 | result += child.surface.size.height; 289 | } 290 | return result; 291 | } 292 | 293 | fn drawBuilder(self: *ListView, ctx: vtk.DrawContext, builder: Builder) Allocator.Error!vtk.Surface { 294 | defer self.scroll.wants_cursor = false; 295 | 296 | // Get the size. asserts neither constraint is null 297 | const max_size = ctx.max.size(); 298 | // Set up surface. 299 | var surface: vtk.Surface = .{ 300 | .size = max_size, 301 | .widget = self.widget(), 302 | .buffer = &.{}, 303 | .children = &.{}, 304 | }; 305 | 306 | // Set state 307 | { 308 | surface.focusable = true; 309 | surface.handles_mouse = true; 310 | // Assume we have more. We only know we don't after drawing 311 | self.scroll.has_more = true; 312 | } 313 | 314 | var child_list = std.ArrayList(vtk.SubSurface).init(ctx.arena); 315 | 316 | // Accumulated height tracks how much height we have drawn. It's initial state is 317 | // (scroll.offset + scroll.pending_lines) lines _above_ the surface top edge. 318 | // Example: 319 | // 1. Scroll up 3 lines: 320 | // pending_lines = -3 321 | // offset = 0 322 | // accumulated_height = -(0 + -3) = 3; 323 | // Our first widget is placed at row 3, we will need to fill this in after the draw 324 | // 2. Scroll up 3 lines, with an offset of 4 325 | // pending_lines = -3 326 | // offset = 4 327 | // accumulated_height = -(4 + -3) = -1; 328 | // Our first widget is placed at row -1 329 | // 3. Scroll down 3 lines: 330 | // pending_lines = 3 331 | // offset = 0 332 | // accumulated_height = -(0 + 3) = -3; 333 | // Our first widget is placed at row -3. It's possible it consumes the entire widget. We 334 | // will check for this at the end and only include visible children 335 | var accumulated_height: i32 = -(self.scroll.offset + self.scroll.pending_lines); 336 | 337 | // We handled the pending scroll by assigning accumulated_height. Reset it's state 338 | self.scroll.pending_lines = 0; 339 | 340 | // Set the initial index for our downard loop. We do this here because we might modify 341 | // scroll.top before we traverse downward 342 | var i: usize = self.scroll.top; 343 | 344 | // If we are on the first item, and we have an upward scroll that consumed our offset, eg 345 | // accumulated_height > 0, we reset state here. We can't scroll up anymore so we set 346 | // accumulated_height to 0. 347 | if (accumulated_height > 0 and self.scroll.top == 0) { 348 | self.scroll.offset = 0; 349 | accumulated_height = 0; 350 | } 351 | 352 | // If we are offset downward, insert widgets to the front of the list before traversing downard 353 | if (accumulated_height > 0) { 354 | try self.insertChildren(ctx, builder, &child_list, accumulated_height); 355 | const last_child = child_list.items[child_list.items.len - 1]; 356 | accumulated_height = last_child.origin.row + last_child.surface.size.height; 357 | } 358 | 359 | const child_offset: u16 = if (self.draw_cursor) 2 else 0; 360 | 361 | while (builder.itemAtIdx(i, self.cursor)) |child| { 362 | // Defer the increment 363 | defer i += 1; 364 | 365 | // Set up constraints. We let the child be the entire height if it wants 366 | const child_ctx = ctx.withConstraints( 367 | .{ .width = max_size.width - child_offset, .height = 0 }, 368 | .{ .width = max_size.width - child_offset, .height = null }, 369 | ); 370 | 371 | // Draw the child 372 | var surf = try child.draw(child_ctx); 373 | // We set the child to non-focusable so that we can manage where the keyevents go 374 | surf.focusable = false; 375 | 376 | // Add the child surface to our list. It's offset from parent is the accumulated height 377 | try child_list.append(.{ 378 | .origin = .{ .col = child_offset, .row = accumulated_height }, 379 | .surface = surf, 380 | .z_index = 0, 381 | }); 382 | 383 | // Accumulate the height 384 | accumulated_height += surf.size.height; 385 | 386 | if (self.scroll.wants_cursor and i < self.cursor) 387 | continue // continue if we want the cursor and haven't gotten there yet 388 | else if (accumulated_height >= max_size.height) 389 | break; // Break if we drew enough 390 | } else { 391 | // This branch runs if we ran out of items. Set our state accordingly 392 | self.scroll.has_more = false; 393 | } 394 | 395 | var total_height: usize = totalHeight(&child_list); 396 | 397 | // If we reached the bottom, don't have enough height to fill the screen, and have room to add 398 | // more, then we add more until out of items or filled the space. This can happen on a resize 399 | if (!self.scroll.has_more and total_height < max_size.height and self.scroll.top > 0) { 400 | try self.insertChildren(ctx, builder, &child_list, @intCast(max_size.height - total_height)); 401 | // Set the new total height 402 | total_height = totalHeight(&child_list); 403 | } 404 | 405 | if (self.draw_cursor and self.cursor >= self.scroll.top) blk: { 406 | // The index of the cursored widget in our child_list 407 | const cursored_idx: u32 = self.cursor - self.scroll.top; 408 | // Nothing to draw if our cursor is below our viewport 409 | if (cursored_idx >= child_list.items.len) break :blk; 410 | 411 | const sub = try ctx.arena.alloc(vtk.SubSurface, 1); 412 | const child = child_list.items[cursored_idx]; 413 | sub[0] = .{ 414 | .origin = .{ .col = child_offset, .row = 0 }, 415 | .surface = child.surface, 416 | .z_index = 0, 417 | }; 418 | const cursor_surf = try vtk.Surface.initWithChildren( 419 | ctx.arena, 420 | self.widget(), 421 | .{ .width = child_offset, .height = child.surface.size.height }, 422 | sub, 423 | ); 424 | for (0..cursor_surf.size.height) |row| { 425 | cursor_surf.writeCell(0, @intCast(row), cursor_indicator); 426 | } 427 | child_list.items[cursored_idx] = .{ 428 | .origin = .{ .col = 0, .row = child.origin.row }, 429 | .surface = cursor_surf, 430 | .z_index = 0, 431 | }; 432 | } 433 | 434 | // If we want the cursor, we check that the cursored widget is fully in view. If it is too 435 | // large, we position it so that it is the top item with a 0 offset 436 | if (self.scroll.wants_cursor) { 437 | const cursored_idx: u32 = self.cursor - self.scroll.top; 438 | const sub = child_list.items[cursored_idx]; 439 | // The bottom row of the cursored widget 440 | const bottom = sub.origin.row + sub.surface.size.height; 441 | if (bottom > max_size.height) { 442 | // Adjust the origin by the difference 443 | // anchor bottom 444 | var origin: i32 = max_size.height; 445 | var idx: usize = cursored_idx + 1; 446 | while (idx > 0) : (idx -= 1) { 447 | var child = child_list.items[idx - 1]; 448 | origin -= child.surface.size.height; 449 | child.origin.row = origin; 450 | child_list.items[idx - 1] = child; 451 | } 452 | } else if (sub.surface.size.height >= max_size.height) { 453 | // TODO: handle when the child is larger than our height. 454 | // We need to change the max constraint to be optional sizes so that we can support 455 | // unbounded drawing in scrollable areas 456 | self.scroll.top = self.cursor; 457 | self.scroll.offset = 0; 458 | child_list.deinit(); 459 | try child_list.append(.{ 460 | .origin = .{ .col = 0, .row = 0 }, 461 | .surface = sub.surface, 462 | .z_index = 0, 463 | }); 464 | total_height = sub.surface.size.height; 465 | } 466 | } 467 | 468 | // If we reached the bottom, we need to reset origins 469 | if (!self.scroll.has_more and total_height < max_size.height) { 470 | // anchor top 471 | assert(self.scroll.top == 0); 472 | self.scroll.offset = 0; 473 | var origin: i32 = 0; 474 | for (0..child_list.items.len) |idx| { 475 | var child = child_list.items[idx]; 476 | child.origin.row = origin; 477 | origin += child.surface.size.height; 478 | child_list.items[idx] = child; 479 | } 480 | } else if (!self.scroll.has_more) { 481 | // anchor bottom 482 | var origin: i32 = max_size.height; 483 | var idx: usize = child_list.items.len; 484 | while (idx > 0) : (idx -= 1) { 485 | var child = child_list.items[idx - 1]; 486 | origin -= child.surface.size.height; 487 | child.origin.row = origin; 488 | child_list.items[idx - 1] = child; 489 | } 490 | } 491 | 492 | var start: usize = 0; 493 | var end: usize = child_list.items.len; 494 | 495 | for (child_list.items, 0..) |child, idx| { 496 | if (child.origin.row <= 0 and child.origin.row + child.surface.size.height > 0) { 497 | start = idx; 498 | self.scroll.offset = -child.origin.row; 499 | self.scroll.top += @intCast(idx); 500 | } 501 | if (child.origin.row > max_size.height) { 502 | end = idx; 503 | break; 504 | } 505 | } 506 | 507 | surface.children = child_list.items[start..end]; 508 | return surface; 509 | } 510 | 511 | const SliceBuilder = struct { 512 | slice: []const vtk.Widget, 513 | 514 | fn build(ptr: *const anyopaque, idx: usize, _: usize) ?vtk.Widget { 515 | const self: *const SliceBuilder = @ptrCast(@alignCast(ptr)); 516 | if (idx >= self.slice.len) return null; 517 | return self.slice[idx]; 518 | } 519 | }; 520 | 521 | test ListView { 522 | // Create child widgets 523 | const Text = @import("Text.zig"); 524 | const abc: Text = .{ .text = "abc\n def\n ghi" }; 525 | const def: Text = .{ .text = "def" }; 526 | const ghi: Text = .{ .text = "ghi" }; 527 | const jklmno: Text = .{ .text = "jkl\n mno" }; 528 | // 0 |*abc 529 | // 1 | def 530 | // 2 | ghi 531 | // 3 | def 532 | // 4 ghi 533 | // 5 jkl 534 | // 6 mno 535 | 536 | // Create the list view 537 | const list_view: ListView = .{ 538 | .wheel_scroll = 1, // Set wheel scroll to one 539 | .children = .{ .slice = &.{ 540 | abc.widget(), 541 | def.widget(), 542 | ghi.widget(), 543 | jklmno.widget(), 544 | } }, 545 | }; 546 | 547 | // Boiler plate draw context 548 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 549 | defer arena.deinit(); 550 | const ucd = try vaxis.Unicode.init(arena.allocator()); 551 | vtk.DrawContext.init(&ucd, .unicode); 552 | 553 | const list_widget = list_view.widget(); 554 | const draw_ctx: vtk.DrawContext = .{ 555 | .arena = arena.allocator(), 556 | .min = .{}, 557 | .max = .{ .width = 16, .height = 4 }, 558 | }; 559 | 560 | var surface = try list_widget.draw(draw_ctx); 561 | // ListView expands to max height and max width 562 | try std.testing.expectEqual(4, surface.size.height); 563 | try std.testing.expectEqual(16, surface.size.width); 564 | // We have 2 children, because only visible children appear as a surface 565 | try std.testing.expectEqual(2, surface.children.len); 566 | 567 | var mouse_event: vaxis.Mouse = .{ 568 | .col = 0, 569 | .row = 0, 570 | .button = .wheel_up, 571 | .mods = .{}, 572 | .type = .press, 573 | }; 574 | // Event handlers need a context 575 | var ctx: vtk.EventContext = .{ 576 | .cmds = std.ArrayList(vtk.Command).init(std.testing.allocator), 577 | }; 578 | defer ctx.cmds.deinit(); 579 | 580 | try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 581 | // Wheel up doesn't adjust the scroll 582 | try std.testing.expectEqual(0, list_view.scroll.top); 583 | try std.testing.expectEqual(0, list_view.scroll.offset); 584 | 585 | // Send a wheel down 586 | mouse_event.button = .wheel_down; 587 | try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 588 | // We have to draw the widget for scrolls to take effect 589 | surface = try list_widget.draw(draw_ctx); 590 | // 0 *abc 591 | // 1 | def 592 | // 2 | ghi 593 | // 3 | def 594 | // 4 | ghi 595 | // 5 jkl 596 | // 6 mno 597 | // We should have gone down 1 line, and not changed our top widget 598 | try std.testing.expectEqual(0, list_view.scroll.top); 599 | try std.testing.expectEqual(1, list_view.scroll.offset); 600 | // One more widget has scrolled into view 601 | try std.testing.expectEqual(3, surface.children.len); 602 | 603 | // Scroll down two more lines 604 | try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 605 | try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 606 | surface = try list_widget.draw(draw_ctx); 607 | // 0 *abc 608 | // 1 def 609 | // 2 ghi 610 | // 3 | def 611 | // 4 | ghi 612 | // 5 | jkl 613 | // 6 | mno 614 | // We should have gone down 2 lines, which scrolls our top widget out of view 615 | try std.testing.expectEqual(1, list_view.scroll.top); 616 | try std.testing.expectEqual(0, list_view.scroll.offset); 617 | try std.testing.expectEqual(3, surface.children.len); 618 | 619 | // Scroll down again. We shouldn't advance anymore since we are at the bottom 620 | try list_widget.handleEvent(&ctx, .{ .mouse = mouse_event }); 621 | surface = try list_widget.draw(draw_ctx); 622 | try std.testing.expectEqual(1, list_view.scroll.top); 623 | try std.testing.expectEqual(0, list_view.scroll.offset); 624 | try std.testing.expectEqual(3, surface.children.len); 625 | 626 | // Mouse wheel events don't change the cursor position. Let's press "escape" to reset the 627 | // viewport and bring our cursor into view 628 | try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.escape } }); 629 | surface = try list_widget.draw(draw_ctx); 630 | try std.testing.expectEqual(0, list_view.scroll.top); 631 | try std.testing.expectEqual(0, list_view.scroll.offset); 632 | try std.testing.expectEqual(2, surface.children.len); 633 | 634 | // Cursor down 635 | try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 636 | surface = try list_widget.draw(draw_ctx); 637 | // 0 | abc 638 | // 1 | def 639 | // 2 | ghi 640 | // 3 |*def 641 | // 4 ghi 642 | // 5 jkl 643 | // 6 mno 644 | // Scroll doesn't change 645 | try std.testing.expectEqual(0, list_view.scroll.top); 646 | try std.testing.expectEqual(0, list_view.scroll.offset); 647 | try std.testing.expectEqual(2, surface.children.len); 648 | try std.testing.expectEqual(1, list_view.cursor); 649 | 650 | // Cursor down 651 | try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 652 | surface = try list_widget.draw(draw_ctx); 653 | // 0 abc 654 | // 1 | def 655 | // 2 | ghi 656 | // 3 | def 657 | // 4 |*ghi 658 | // 5 jkl 659 | // 6 mno 660 | // Scroll advances one row 661 | try std.testing.expectEqual(0, list_view.scroll.top); 662 | try std.testing.expectEqual(1, list_view.scroll.offset); 663 | try std.testing.expectEqual(3, surface.children.len); 664 | try std.testing.expectEqual(2, list_view.cursor); 665 | 666 | // Cursor down 667 | try list_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'j' } }); 668 | surface = try list_widget.draw(draw_ctx); 669 | // 0 abc 670 | // 1 def 671 | // 2 ghi 672 | // 3 | def 673 | // 4 | ghi 674 | // 5 |*jkl 675 | // 6 | mno 676 | // We are cursored onto the last item. The entire last item comes into view, effectively 677 | // advancing the scroll by 2 678 | try std.testing.expectEqual(1, list_view.scroll.top); 679 | try std.testing.expectEqual(0, list_view.scroll.offset); 680 | try std.testing.expectEqual(3, surface.children.len); 681 | try std.testing.expectEqual(3, list_view.cursor); 682 | } 683 | 684 | test "refAllDecls" { 685 | std.testing.refAllDecls(@This()); 686 | } 687 | --------------------------------------------------------------------------------