a modern tui library written in zig
at main 8.4 kB view raw
1const std = @import("std"); 2const vaxis = @import("vaxis"); 3const vxfw = vaxis.vxfw; 4 5const Model = struct { 6 list: std.ArrayList(vxfw.Text), 7 /// Memory owned by .arena 8 filtered: std.ArrayList(vxfw.RichText), 9 list_view: vxfw.ListView, 10 text_field: vxfw.TextField, 11 12 /// Used for filtered RichText Spans and result 13 arena: std.heap.ArenaAllocator, 14 filtered: std.ArrayList(vxfw.RichText), 15 result: []const u8, 16 17 pub fn init(gpa: std.mem.Allocator) !*Model { 18 const model = try gpa.create(Model); 19 errdefer gpa.destroy(model); 20 21 model.* = .{ 22 .list = .empty, 23 .filtered = .empty, 24 .list_view = .{ 25 .children = .{ 26 .builder = .{ 27 .userdata = model, 28 .buildFn = Model.widgetBuilder, 29 }, 30 }, 31 }, 32 .text_field = .{ 33 .buf = vxfw.TextField.Buffer.init(gpa), 34 .userdata = model, 35 .onChange = Model.onChange, 36 .onSubmit = Model.onSubmit, 37 }, 38 .result = "", 39 .arena = std.heap.ArenaAllocator.init(gpa), 40 }; 41 42 return model; 43 } 44 45 pub fn deinit(self: *Model, gpa: std.mem.Allocator) void { 46 self.arena.deinit(); 47 self.text_field.deinit(); 48 self.list.deinit(gpa); 49 gpa.destroy(self); 50 } 51 52 pub fn widget(self: *Model) vxfw.Widget { 53 return .{ 54 .userdata = self, 55 .eventHandler = Model.typeErasedEventHandler, 56 .drawFn = Model.typeErasedDrawFn, 57 }; 58 } 59 60 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 61 const self: *Model = @ptrCast(@alignCast(ptr)); 62 switch (event) { 63 .init => { 64 // Initialize the filtered list 65 const arena = self.arena.allocator(); 66 for (self.list.items) |line| { 67 var spans = std.ArrayList(vxfw.RichText.TextSpan).empty; 68 const span: vxfw.RichText.TextSpan = .{ .text = line.text }; 69 try spans.append(arena, span); 70 try self.filtered.append(arena, .{ .text = spans.items }); 71 } 72 73 return ctx.requestFocus(self.text_field.widget()); 74 }, 75 .key_press => |key| { 76 if (key.matches('c', .{ .ctrl = true })) { 77 ctx.quit = true; 78 return; 79 } 80 return self.list_view.handleEvent(ctx, event); 81 }, 82 .focus_in => { 83 return ctx.requestFocus(self.text_field.widget()); 84 }, 85 else => {}, 86 } 87 } 88 89 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { 90 const self: *Model = @ptrCast(@alignCast(ptr)); 91 const max = ctx.max.size(); 92 93 const list_view: vxfw.SubSurface = .{ 94 .origin = .{ .row = 2, .col = 0 }, 95 .surface = try self.list_view.draw(ctx.withConstraints( 96 ctx.min, 97 .{ .width = max.width, .height = max.height - 3 }, 98 )), 99 }; 100 101 const text_field: vxfw.SubSurface = .{ 102 .origin = .{ .row = 0, .col = 2 }, 103 .surface = try self.text_field.draw(ctx.withConstraints( 104 ctx.min, 105 .{ .width = max.width, .height = 1 }, 106 )), 107 }; 108 109 const prompt: vxfw.Text = .{ .text = "", .style = .{ .fg = .{ .index = 4 } } }; 110 111 const prompt_surface: vxfw.SubSurface = .{ 112 .origin = .{ .row = 0, .col = 0 }, 113 .surface = try prompt.draw(ctx.withConstraints(ctx.min, .{ .width = 2, .height = 1 })), 114 }; 115 116 const children = try ctx.arena.alloc(vxfw.SubSurface, 3); 117 children[0] = list_view; 118 children[1] = text_field; 119 children[2] = prompt_surface; 120 121 return .{ 122 .size = max, 123 .widget = self.widget(), 124 .buffer = &.{}, 125 .children = children, 126 }; 127 } 128 129 fn widgetBuilder(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 130 const self: *const Model = @ptrCast(@alignCast(ptr)); 131 if (idx >= self.filtered.items.len) return null; 132 133 return self.filtered.items[idx].widget(); 134 } 135 136 fn onChange(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, str: []const u8) anyerror!void { 137 const ptr = maybe_ptr orelse return; 138 const self: *Model = @ptrCast(@alignCast(ptr)); 139 const arena = self.arena.allocator(); 140 self.filtered.clearAndFree(arena); 141 _ = self.arena.reset(.free_all); 142 143 const hasUpper = for (str) |b| { 144 if (std.ascii.isUpper(b)) break true; 145 } else false; 146 147 // Loop each line 148 // If our input is only lowercase, we convert the line to lowercase 149 // Iterate the input graphemes, looking for them _in order_ in the line 150 outer: for (self.list.items) |item| { 151 const tgt = if (hasUpper) 152 item.text 153 else 154 try toLower(arena, item.text); 155 156 var spans = std.ArrayList(vxfw.RichText.TextSpan).empty; 157 var i: usize = 0; 158 var iter = vaxis.unicode.graphemeIterator(str); 159 while (iter.next()) |g| { 160 if (std.mem.indexOfPos(u8, tgt, i, g.bytes(str))) |idx| { 161 const up_to_here: vxfw.RichText.TextSpan = .{ .text = item.text[i..idx] }; 162 const match: vxfw.RichText.TextSpan = .{ 163 .text = item.text[idx .. idx + g.len], 164 .style = .{ .fg = .{ .index = 4 }, .reverse = true }, 165 }; 166 try spans.append(arena, up_to_here); 167 try spans.append(arena, match); 168 i = idx + g.len; 169 } else continue :outer; 170 } 171 const up_to_here: vxfw.RichText.TextSpan = .{ .text = item.text[i..] }; 172 try spans.append(arena, up_to_here); 173 try self.filtered.append(arena, .{ .text = spans.items }); 174 } 175 self.list_view.scroll.top = 0; 176 self.list_view.scroll.offset = 0; 177 self.list_view.cursor = 0; 178 } 179 180 fn onSubmit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void { 181 const ptr = maybe_ptr orelse return; 182 const self: *Model = @ptrCast(@alignCast(ptr)); 183 if (self.list_view.cursor < self.filtered.items.len) { 184 const selected = self.filtered.items[self.list_view.cursor]; 185 const arena = self.arena.allocator(); 186 var result = std.ArrayList(u8).empty; 187 for (selected.text) |span| { 188 try result.appendSlice(arena, span.text); 189 } 190 self.result = result.items; 191 } 192 ctx.quit = true; 193 } 194}; 195 196fn toLower(arena: std.mem.Allocator, src: []const u8) std.mem.Allocator.Error![]const u8 { 197 const lower = try arena.alloc(u8, src.len); 198 for (src, 0..) |b, i| { 199 lower[i] = std.ascii.toLower(b); 200 } 201 return lower; 202} 203 204pub fn main() !void { 205 var debug_allocator = std.heap.GeneralPurposeAllocator(.{}){}; 206 defer _ = debug_allocator.deinit(); 207 208 const gpa = debug_allocator.allocator(); 209 210 var app = try vxfw.App.init(gpa); 211 errdefer app.deinit(); 212 213 const model = try Model.init(gpa); 214 defer model.deinit(gpa); 215 216 // Run the command 217 var fd = std.process.Child.init(&.{"fd"}, gpa); 218 fd.stdout_behavior = .Pipe; 219 fd.stderr_behavior = .Pipe; 220 var stdout = std.ArrayList(u8).empty; 221 var stderr = std.ArrayList(u8).empty; 222 defer stdout.deinit(gpa); 223 defer stderr.deinit(gpa); 224 try fd.spawn(); 225 try fd.collectOutput(gpa, &stdout, &stderr, 10_000_000); 226 _ = try fd.wait(); 227 228 var iter = std.mem.splitScalar(u8, stdout.items, '\n'); 229 while (iter.next()) |line| { 230 if (line.len == 0) continue; 231 try model.list.append(gpa, .{ .text = line }); 232 } 233 234 try app.run(model.widget(), .{}); 235 app.deinit(); 236 237 if (model.result.len > 0) { 238 _ = try std.posix.write(std.posix.STDOUT_FILENO, model.result); 239 _ = try std.posix.write(std.posix.STDOUT_FILENO, "\n"); 240 } else { 241 std.process.exit(130); 242 } 243}