a modern tui library written in zig
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}