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: usize = 0,
13 /// Current active Column of the Table.
14 col: usize = 0,
15 /// Starting point within the Data List.
16 start: usize = 0,
17 /// Selected Rows.
18 sel_rows: ?[]usize = 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!usize = 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: usize = 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: usize = 0,
51 /// X Offset for printing each Cell/Item.
52 cell_x_off: usize = 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: usize,
82 /// Statically set all Column Widths to the same value.
83 static_all: usize,
84 /// Statically set individual Column Widths to specific values.
85 static_individual: []const usize,
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).init(_alloc);
174 for (0..mal_slice.len) |idx| try data_out_list.append(mal_slice.get(idx));
175 break :getData try data_out_list.toOwnedSlice();
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.initChild(
213 0,
214 table_ctx.y_off,
215 .{ .limit = win.width },
216 .{ .limit = win.height },
217 );
218
219 // Headers
220 if (table_ctx.col > headers.len - 1) table_ctx.col = headers.len - 1;
221 var col_start: usize = 0;
222 for (headers[0..], 0..) |hdr_txt, idx| {
223 const col_width = try calcColWidth(
224 idx,
225 headers,
226 table_ctx.col_width,
227 table_win,
228 );
229 defer col_start += col_width;
230 const hdr_fg, const hdr_bg = hdrColors: {
231 if (table_ctx.active and idx == table_ctx.col)
232 break :hdrColors .{ table_ctx.active_fg, table_ctx.active_bg }
233 else if (idx % 2 == 0)
234 break :hdrColors .{ .default, table_ctx.hdr_bg_1 }
235 else
236 break :hdrColors .{ .default, table_ctx.hdr_bg_2 };
237 };
238 const hdr_win = table_win.child(.{
239 .x_off = col_start,
240 .y_off = 0,
241 .width = .{ .limit = col_width },
242 .height = .{ .limit = 1 },
243 .border = .{ .where = if (table_ctx.header_borders and idx > 0) .left else .none },
244 });
245 var hdr = switch (table_ctx.header_align) {
246 .left => hdr_win,
247 .center => vaxis.widgets.alignment.center(hdr_win, @min(col_width -| 1, hdr_txt.len +| 1), 1),
248 };
249 hdr_win.fill(.{ .style = .{ .bg = hdr_bg } });
250 var seg = [_]vaxis.Cell.Segment{.{
251 .text = if (hdr_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{hdr_txt[0..(col_width -| 4)]}) else hdr_txt,
252 .style = .{
253 .fg = hdr_fg,
254 .bg = hdr_bg,
255 .bold = true,
256 .ul_style = if (idx == table_ctx.col) .single else .dotted,
257 },
258 }};
259 _ = try hdr.print(seg[0..], .{ .wrap = .word });
260 }
261
262 // Rows
263 if (table_ctx.active_content_fn == null) table_ctx.active_y_off = 0;
264 const max_items =
265 if (data_items.len > table_win.height -| 1) table_win.height -| 1 else data_items.len;
266 var end = table_ctx.start + max_items;
267 if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2)
268 end -|= table_ctx.active_y_off;
269 if (end > data_items.len) end = data_items.len;
270 table_ctx.start = tableStart: {
271 if (table_ctx.row == 0)
272 break :tableStart 0;
273 if (table_ctx.row < table_ctx.start)
274 break :tableStart table_ctx.start - (table_ctx.start - table_ctx.row);
275 if (table_ctx.row >= data_items.len - 1)
276 table_ctx.row = data_items.len - 1;
277 if (table_ctx.row >= end)
278 break :tableStart table_ctx.start + (table_ctx.row - end + 1);
279 break :tableStart table_ctx.start;
280 };
281 end = table_ctx.start + max_items;
282 if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2)
283 end -|= table_ctx.active_y_off;
284 if (end > data_items.len) end = data_items.len;
285 table_ctx.start = @min(table_ctx.start, end);
286 table_ctx.active_y_off = 0;
287 for (data_items[table_ctx.start..end], 0..) |data, row| {
288 const row_fg, const row_bg = rowColors: {
289 if (table_ctx.active and table_ctx.start + row == table_ctx.row)
290 break :rowColors .{ table_ctx.active_fg, table_ctx.active_bg };
291 if (table_ctx.sel_rows) |rows| {
292 if (mem.indexOfScalar(usize, rows, table_ctx.start + row) != null)
293 break :rowColors .{ table_ctx.selected_fg, table_ctx.selected_bg };
294 }
295 if (row % 2 == 0) break :rowColors .{ .default, table_ctx.row_bg_1 };
296 break :rowColors .{ .default, table_ctx.row_bg_2 };
297 };
298 var row_win = table_win.child(.{
299 .x_off = 0,
300 .y_off = 1 + row + table_ctx.active_y_off,
301 .width = .{ .limit = table_win.width },
302 .height = .{ .limit = 1 },
303 //.border = .{ .where = if (table_ctx.row_borders) .top else .none },
304 });
305 if (table_ctx.start + row == table_ctx.row) {
306 table_ctx.active_y_off = if (table_ctx.active_content_fn) |content| try content(&row_win, table_ctx.active_ctx) else 0;
307 }
308 col_start = 0;
309 const item_fields = meta.fields(DataT);
310 var col_idx: usize = 0;
311 for (field_indexes) |f_idx| {
312 inline for (item_fields[0..], 0..) |item_field, item_idx| contFields: {
313 switch (table_ctx.col_indexes) {
314 .all => {},
315 .by_idx => {
316 if (item_idx != f_idx) break :contFields;
317 },
318 }
319 defer col_idx += 1;
320 const col_width = try calcColWidth(
321 item_idx,
322 headers,
323 table_ctx.col_width,
324 table_win,
325 );
326 defer col_start += col_width;
327 const item = @field(data, item_field.name);
328 const ItemT = @TypeOf(item);
329 const item_win = row_win.child(.{
330 .x_off = col_start,
331 .y_off = 0,
332 .width = .{ .limit = col_width },
333 .height = .{ .limit = 1 },
334 .border = .{ .where = if (table_ctx.col_borders and col_idx > 0) .left else .none },
335 });
336 const item_txt = switch (ItemT) {
337 []const u8 => item,
338 [][]const u8, []const []const u8 => strSlice: {
339 if (alloc) |_alloc| break :strSlice try fmt.allocPrint(_alloc, "{s}", .{item});
340 break :strSlice item;
341 },
342 else => nonStr: {
343 switch (@typeInfo(ItemT)) {
344 .Enum => break :nonStr @tagName(item),
345 .Optional => {
346 const opt_item = item orelse break :nonStr "-";
347 switch (@typeInfo(ItemT).Optional.child) {
348 []const u8 => break :nonStr opt_item,
349 [][]const u8, []const []const u8 => {
350 break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{s}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)});
351 },
352 else => {
353 break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)});
354 },
355 }
356 },
357 else => {
358 break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)});
359 },
360 }
361 },
362 };
363 item_win.fill(.{ .style = .{ .bg = row_bg } });
364 const item_align_win = itemAlignWin: {
365 const col_align = switch (table_ctx.col_align) {
366 .all => |all| all,
367 .by_idx => |aligns| aligns[col_idx],
368 };
369 break :itemAlignWin switch (col_align) {
370 .left => item_win,
371 .center => center: {
372 const center = vaxis.widgets.alignment.center(item_win, @min(col_width -| 1, item_txt.len +| 1), 1);
373 center.fill(.{ .style = .{ .bg = row_bg } });
374 break :center center;
375 },
376 };
377 };
378 var seg = [_]vaxis.Cell.Segment{.{
379 .text = if (item_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{item_txt[0..(col_width -| 4)]}) else item_txt,
380 .style = .{ .fg = row_fg, .bg = row_bg },
381 }};
382 _ = try item_align_win.print(seg[0..], .{ .wrap = .word, .col_offset = table_ctx.cell_x_off });
383 }
384 }
385 }
386}
387
388/// Calculate the Column Width of `col` using the provided Number of Headers (`num_hdrs`), Width Style (`style`), and Table Window (`table_win`).
389pub fn calcColWidth(
390 col: usize,
391 headers: []const []const u8,
392 style: WidthStyle,
393 table_win: vaxis.Window,
394) !usize {
395 return switch (style) {
396 .dynamic_fill => dynFill: {
397 var cw = table_win.width / headers.len;
398 if (cw % 2 != 0) cw +|= 1;
399 while (cw * headers.len < table_win.width - 1) cw +|= 1;
400 break :dynFill cw;
401 },
402 .dynamic_header_len => dynHdrs: {
403 if (col >= headers.len) break :dynHdrs error.NotEnoughStaticWidthsProvided;
404 break :dynHdrs headers[col].len + (style.dynamic_header_len * 2);
405 },
406 .static_all => style.static_all,
407 .static_individual => statInd: {
408 if (col >= headers.len) break :statInd error.NotEnoughStaticWidthsProvided;
409 break :statInd style.static_individual[col];
410 },
411 };
412}