a modern tui library written in zig
1const std = @import("std");
2const fmt = std.fmt;
3const heap = std.heap;
4const mem = std.mem;
5const meta = std.meta;
6
7const vaxis = @import("../main.zig");
8
9/// Table Context for maintaining state and drawing Tables with `drawTable()`.
10pub const TableContext = struct {
11 /// Current active Row of the Table.
12 row: u16 = 0,
13 /// Current active Column of the Table.
14 col: u16 = 0,
15 /// Starting point within the Data List.
16 start: u16 = 0,
17 /// Selected Rows.
18 sel_rows: ?[]u16 = null,
19
20 /// Active status of the Table.
21 active: bool = false,
22 /// Active Content Callback Function.
23 /// If available, this will be called to vertically expand the active row with additional info.
24 active_content_fn: ?*const fn (*vaxis.Window, *const anyopaque) anyerror!u16 = null,
25 /// Active Content Context
26 /// This will be provided to the `active_content` callback when called.
27 active_ctx: *const anyopaque = &{},
28 /// Y Offset for rows beyond the Active Content.
29 /// (This will be calculated automatically)
30 active_y_off: u16 = 0,
31
32 /// The Background Color for Selected Rows.
33 selected_bg: vaxis.Cell.Color,
34 /// The Foreground Color for Selected Rows.
35 selected_fg: vaxis.Cell.Color = .default,
36 /// The Background Color for the Active Row and Column Header.
37 active_bg: vaxis.Cell.Color,
38 /// The Foreground Color for the Active Row and Column Header.
39 active_fg: vaxis.Cell.Color = .default,
40 /// First Column Header Background Color
41 hdr_bg_1: vaxis.Cell.Color = .{ .rgb = [_]u8{ 64, 64, 64 } },
42 /// Second Column Header Background Color
43 hdr_bg_2: vaxis.Cell.Color = .{ .rgb = [_]u8{ 8, 8, 24 } },
44 /// First Row Background Color
45 row_bg_1: vaxis.Cell.Color = .{ .rgb = [_]u8{ 32, 32, 32 } },
46 /// Second Row Background Color
47 row_bg_2: vaxis.Cell.Color = .{ .rgb = [_]u8{ 8, 8, 8 } },
48
49 /// Y Offset for drawing to the parent Window.
50 y_off: u16 = 0,
51 /// X Offset for printing each Cell/Item.
52 cell_x_off: u16 = 1,
53
54 /// Column Width
55 /// Note, if this is left `null` the Column Width will be dynamically calculated during `drawTable()`.
56 //col_width: ?usize = null,
57 col_width: WidthStyle = .dynamic_fill,
58
59 // Header Names
60 header_names: HeaderNames = .field_names,
61 // Column Indexes
62 col_indexes: ColumnIndexes = .all,
63 // Header Alignment
64 header_align: HorizontalAlignment = .center,
65 // Column Alignment
66 col_align: ColumnAlignment = .{ .all = .left },
67
68 // Header Borders
69 header_borders: bool = false,
70 // Row Borders
71 //row_borders: bool = false,
72 // Col Borders
73 col_borders: bool = false,
74};
75
76/// Width Styles for `col_width`.
77pub const WidthStyle = union(enum) {
78 /// Dynamically calculate Column Widths such that the entire (or most) of the screen is filled horizontally.
79 dynamic_fill,
80 /// Dynamically calculate the Column Width for each Column based on its Header Length and the provided Padding length.
81 dynamic_header_len: u16,
82 /// Statically set all Column Widths to the same value.
83 static_all: u16,
84 /// Statically set individual Column Widths to specific values.
85 static_individual: []const u16,
86};
87
88/// Column Indexes
89pub const ColumnIndexes = union(enum) {
90 /// Use all of the Columns.
91 all,
92 /// Use Columns from the specified indexes.
93 by_idx: []const usize,
94};
95
96/// Header Names
97pub const HeaderNames = union(enum) {
98 /// Use Field Names as Headers
99 field_names,
100 /// Custom
101 custom: []const []const u8,
102};
103
104/// Horizontal Alignment
105pub const HorizontalAlignment = enum {
106 left,
107 center,
108};
109/// Column Alignment
110pub const ColumnAlignment = union(enum) {
111 all: HorizontalAlignment,
112 by_idx: []const HorizontalAlignment,
113};
114
115/// Draw a Table for the TUI.
116pub fn drawTable(
117 /// This should be an ArenaAllocator that can be deinitialized after each event call.
118 /// The Allocator is only used in three cases:
119 /// 1. If a cell is a non-String. (If the Allocator is not provided, those cells will show "[unsupported (TypeName)]".)
120 /// 2. To show that a value is too large to fit into a cell using '...'. (If the Allocator is not provided, they'll just be cutoff.)
121 /// 3. To copy a MultiArrayList into a normal slice. (Note, this is an expensive operation. Prefer to pass a Slice or ArrayList if possible.)
122 alloc: ?mem.Allocator,
123 /// The parent Window to draw to.
124 win: vaxis.Window,
125 /// This must be a Slice, ArrayList, or MultiArrayList.
126 /// Note, MultiArrayList support currently requires allocation.
127 data_list: anytype,
128 // The Table Context for this Table.
129 table_ctx: *TableContext,
130) !void {
131 var di_is_mal = false;
132 const data_items = getData: {
133 const DataListT = @TypeOf(data_list);
134 const data_ti = @typeInfo(DataListT);
135 switch (data_ti) {
136 .pointer => |ptr| {
137 if (ptr.size != .slice) return error.UnsupportedTableDataType;
138 break :getData data_list;
139 },
140 .@"struct" => {
141 const di_fields = meta.fields(DataListT);
142 const al_fields = meta.fields(std.ArrayList([]const u8));
143 const mal_fields = meta.fields(std.MultiArrayList(struct { a: u8 = 0, b: u32 = 0 }));
144 // Probably an ArrayList
145 const is_al = comptime if (mem.indexOf(u8, @typeName(DataListT), "MultiArrayList") == null and
146 mem.indexOf(u8, @typeName(DataListT), "ArrayList") != null and
147 al_fields.len == di_fields.len)
148 isAL: {
149 var is = true;
150 for (al_fields, di_fields) |al_field, di_field|
151 is = is and mem.eql(u8, al_field.name, di_field.name);
152 break :isAL is;
153 } else false;
154 if (is_al) break :getData data_list.items;
155
156 // Probably a MultiArrayList
157 const is_mal = if (mem.indexOf(u8, @typeName(DataListT), "MultiArrayList") != null and
158 mal_fields.len == di_fields.len)
159 isMAL: {
160 var is = true;
161 inline for (mal_fields, di_fields) |mal_field, di_field|
162 is = is and mem.eql(u8, mal_field.name, di_field.name);
163 break :isMAL is;
164 } else false;
165 if (!is_mal) return error.UnsupportedTableDataType;
166 if (alloc) |_alloc| {
167 di_is_mal = true;
168 const mal_slice = data_list.slice();
169 const DataT = dataType: {
170 const fn_info = @typeInfo(@TypeOf(@field(@TypeOf(mal_slice), "get")));
171 break :dataType fn_info.@"fn".return_type orelse @panic("No Child Type");
172 };
173 var data_out_list = std.ArrayList(DataT){};
174 for (0..mal_slice.len) |idx| try data_out_list.append(_alloc, mal_slice.get(idx));
175 break :getData try data_out_list.toOwnedSlice(_alloc);
176 }
177 return error.UnsupportedTableDataType;
178 },
179 else => return error.UnsupportedTableDataType,
180 }
181 };
182 defer if (di_is_mal) alloc.?.free(data_items);
183 const DataT = @TypeOf(data_items[0]);
184 const fields = meta.fields(DataT);
185 const field_indexes = switch (table_ctx.col_indexes) {
186 .all => comptime allIdx: {
187 var indexes_buf: [fields.len]usize = undefined;
188 for (0..fields.len) |idx| indexes_buf[idx] = idx;
189 const indexes = indexes_buf;
190 break :allIdx indexes[0..];
191 },
192 .by_idx => |by_idx| by_idx,
193 };
194
195 // Headers for the Table
196 var hdrs_buf: [fields.len][]const u8 = undefined;
197 const headers = hdrs: {
198 switch (table_ctx.header_names) {
199 .field_names => {
200 for (field_indexes) |f_idx| {
201 inline for (fields, 0..) |field, idx| {
202 if (f_idx == idx)
203 hdrs_buf[idx] = field.name;
204 }
205 }
206 break :hdrs hdrs_buf[0..];
207 },
208 .custom => |hdrs| break :hdrs hdrs,
209 }
210 };
211
212 const table_win = win.child(.{
213 .y_off = table_ctx.y_off,
214 .width = win.width,
215 .height = win.height,
216 });
217
218 // Headers
219 if (table_ctx.col > headers.len - 1) table_ctx.col = @intCast(headers.len - 1);
220 var col_start: u16 = 0;
221 for (headers[0..], 0..) |hdr_txt, idx| {
222 const col_width = try calcColWidth(
223 @intCast(idx),
224 headers,
225 table_ctx.col_width,
226 table_win,
227 );
228 defer col_start += col_width;
229 const hdr_fg, const hdr_bg = hdrColors: {
230 if (table_ctx.active and idx == table_ctx.col)
231 break :hdrColors .{ table_ctx.active_fg, table_ctx.active_bg }
232 else if (idx % 2 == 0)
233 break :hdrColors .{ .default, table_ctx.hdr_bg_1 }
234 else
235 break :hdrColors .{ .default, table_ctx.hdr_bg_2 };
236 };
237 const hdr_win = table_win.child(.{
238 .x_off = col_start,
239 .y_off = 0,
240 .width = col_width,
241 .height = 1,
242 .border = .{ .where = if (table_ctx.header_borders and idx > 0) .left else .none },
243 });
244 var hdr = switch (table_ctx.header_align) {
245 .left => hdr_win,
246 .center => vaxis.widgets.alignment.center(hdr_win, @min(col_width -| 1, hdr_txt.len +| 1), 1),
247 };
248 hdr_win.fill(.{ .style = .{ .bg = hdr_bg } });
249 var seg = [_]vaxis.Cell.Segment{.{
250 .text = if (hdr_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{hdr_txt[0..(col_width -| 4)]}) else hdr_txt,
251 .style = .{
252 .fg = hdr_fg,
253 .bg = hdr_bg,
254 .bold = true,
255 .ul_style = if (idx == table_ctx.col) .single else .dotted,
256 },
257 }};
258 _ = hdr.print(seg[0..], .{ .wrap = .word });
259 }
260
261 // Rows
262 if (table_ctx.active_content_fn == null) table_ctx.active_y_off = 0;
263 const max_items: u16 =
264 if (data_items.len > table_win.height -| 1) table_win.height -| 1 else @intCast(data_items.len);
265 var end = table_ctx.start + max_items;
266 if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2)
267 end -|= table_ctx.active_y_off;
268 if (end > data_items.len) end = @intCast(data_items.len);
269 table_ctx.start = tableStart: {
270 if (table_ctx.row == 0)
271 break :tableStart 0;
272 if (table_ctx.row < table_ctx.start)
273 break :tableStart table_ctx.start - (table_ctx.start - table_ctx.row);
274 if (table_ctx.row >= data_items.len - 1)
275 table_ctx.row = @intCast(data_items.len - 1);
276 if (table_ctx.row >= end)
277 break :tableStart table_ctx.start + (table_ctx.row - end + 1);
278 break :tableStart table_ctx.start;
279 };
280 end = table_ctx.start + max_items;
281 if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2)
282 end -|= table_ctx.active_y_off;
283 if (end > data_items.len) end = @intCast(data_items.len);
284 table_ctx.start = @min(table_ctx.start, end);
285 table_ctx.active_y_off = 0;
286 for (data_items[table_ctx.start..end], 0..) |data, row| {
287 const row_fg, const row_bg = rowColors: {
288 if (table_ctx.active and table_ctx.start + row == table_ctx.row)
289 break :rowColors .{ table_ctx.active_fg, table_ctx.active_bg };
290 if (table_ctx.sel_rows) |rows| {
291 if (mem.indexOfScalar(u16, rows, @intCast(table_ctx.start + row)) != null)
292 break :rowColors .{ table_ctx.selected_fg, table_ctx.selected_bg };
293 }
294 if (row % 2 == 0) break :rowColors .{ .default, table_ctx.row_bg_1 };
295 break :rowColors .{ .default, table_ctx.row_bg_2 };
296 };
297 var row_win = table_win.child(.{
298 .x_off = 0,
299 .y_off = @intCast(1 + row + table_ctx.active_y_off),
300 .width = table_win.width,
301 .height = 1,
302 //.border = .{ .where = if (table_ctx.row_borders) .top else .none },
303 });
304 if (table_ctx.start + row == table_ctx.row) {
305 table_ctx.active_y_off = if (table_ctx.active_content_fn) |content| try content(&row_win, table_ctx.active_ctx) else 0;
306 }
307 col_start = 0;
308 const item_fields = meta.fields(DataT);
309 var col_idx: usize = 0;
310 for (field_indexes) |f_idx| {
311 inline for (item_fields[0..], 0..) |item_field, item_idx| contFields: {
312 switch (table_ctx.col_indexes) {
313 .all => {},
314 .by_idx => {
315 if (item_idx != f_idx) break :contFields;
316 },
317 }
318 defer col_idx += 1;
319 const col_width = try calcColWidth(
320 item_idx,
321 headers,
322 table_ctx.col_width,
323 table_win,
324 );
325 defer col_start += col_width;
326 const item = @field(data, item_field.name);
327 const ItemT = @TypeOf(item);
328 const item_win = row_win.child(.{
329 .x_off = col_start,
330 .y_off = 0,
331 .width = col_width,
332 .height = 1,
333 .border = .{ .where = if (table_ctx.col_borders and col_idx > 0) .left else .none },
334 });
335 const item_txt = switch (ItemT) {
336 []const u8 => item,
337 [][]const u8, []const []const u8 => strSlice: {
338 if (alloc) |_alloc| break :strSlice try fmt.allocPrint(_alloc, "{s}", .{item});
339 break :strSlice item;
340 },
341 else => nonStr: {
342 switch (@typeInfo(ItemT)) {
343 .@"enum" => break :nonStr @tagName(item),
344 .optional => {
345 const opt_item = item orelse break :nonStr "-";
346 switch (@typeInfo(ItemT).optional.child) {
347 []const u8 => break :nonStr opt_item,
348 [][]const u8, []const []const u8 => {
349 break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{s}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)});
350 },
351 else => {
352 break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)});
353 },
354 }
355 },
356 else => {
357 break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)});
358 },
359 }
360 },
361 };
362 item_win.fill(.{ .style = .{ .bg = row_bg } });
363 const item_align_win = itemAlignWin: {
364 const col_align = switch (table_ctx.col_align) {
365 .all => |all| all,
366 .by_idx => |aligns| aligns[col_idx],
367 };
368 break :itemAlignWin switch (col_align) {
369 .left => item_win,
370 .center => center: {
371 const center = vaxis.widgets.alignment.center(item_win, @min(col_width -| 1, item_txt.len +| 1), 1);
372 center.fill(.{ .style = .{ .bg = row_bg } });
373 break :center center;
374 },
375 };
376 };
377 var seg = [_]vaxis.Cell.Segment{.{
378 .text = if (item_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{item_txt[0..(col_width -| 4)]}) else item_txt,
379 .style = .{ .fg = row_fg, .bg = row_bg },
380 }};
381 _ = item_align_win.print(seg[0..], .{ .wrap = .word, .col_offset = table_ctx.cell_x_off });
382 }
383 }
384 }
385}
386
387/// Calculate the Column Width of `col` using the provided Number of Headers (`num_hdrs`), Width Style (`style`), and Table Window (`table_win`).
388pub fn calcColWidth(
389 col: u16,
390 headers: []const []const u8,
391 style: WidthStyle,
392 table_win: vaxis.Window,
393) !u16 {
394 return switch (style) {
395 .dynamic_fill => dynFill: {
396 var cw: u16 = table_win.width / @as(u16, @intCast(headers.len));
397 if (cw % 2 != 0) cw +|= 1;
398 while (cw * headers.len < table_win.width - 1) cw +|= 1;
399 break :dynFill cw;
400 },
401 .dynamic_header_len => dynHdrs: {
402 if (col >= headers.len) break :dynHdrs error.NotEnoughStaticWidthsProvided;
403 break :dynHdrs @as(u16, @intCast(headers[col].len)) + (style.dynamic_header_len * 2);
404 },
405 .static_all => style.static_all,
406 .static_individual => statInd: {
407 if (col >= headers.len) break :statInd error.NotEnoughStaticWidthsProvided;
408 break :statInd style.static_individual[col];
409 },
410 };
411}