const std = @import("std"); const fmt = std.fmt; const heap = std.heap; const mem = std.mem; const meta = std.meta; const vaxis = @import("../main.zig"); /// Table Context for maintaining state and drawing Tables with `drawTable()`. pub const TableContext = struct { /// Current active Row of the Table. row: u16 = 0, /// Current active Column of the Table. col: u16 = 0, /// Starting point within the Data List. start: u16 = 0, /// Selected Rows. sel_rows: ?[]u16 = null, /// Active status of the Table. active: bool = false, /// Active Content Callback Function. /// If available, this will be called to vertically expand the active row with additional info. active_content_fn: ?*const fn (*vaxis.Window, *const anyopaque) anyerror!u16 = null, /// Active Content Context /// This will be provided to the `active_content` callback when called. active_ctx: *const anyopaque = &{}, /// Y Offset for rows beyond the Active Content. /// (This will be calculated automatically) active_y_off: u16 = 0, /// The Background Color for Selected Rows. selected_bg: vaxis.Cell.Color, /// The Foreground Color for Selected Rows. selected_fg: vaxis.Cell.Color = .default, /// The Background Color for the Active Row and Column Header. active_bg: vaxis.Cell.Color, /// The Foreground Color for the Active Row and Column Header. active_fg: vaxis.Cell.Color = .default, /// First Column Header Background Color hdr_bg_1: vaxis.Cell.Color = .{ .rgb = [_]u8{ 64, 64, 64 } }, /// Second Column Header Background Color hdr_bg_2: vaxis.Cell.Color = .{ .rgb = [_]u8{ 8, 8, 24 } }, /// First Row Background Color row_bg_1: vaxis.Cell.Color = .{ .rgb = [_]u8{ 32, 32, 32 } }, /// Second Row Background Color row_bg_2: vaxis.Cell.Color = .{ .rgb = [_]u8{ 8, 8, 8 } }, /// Y Offset for drawing to the parent Window. y_off: u16 = 0, /// X Offset for printing each Cell/Item. cell_x_off: u16 = 1, /// Column Width /// Note, if this is left `null` the Column Width will be dynamically calculated during `drawTable()`. //col_width: ?usize = null, col_width: WidthStyle = .dynamic_fill, // Header Names header_names: HeaderNames = .field_names, // Column Indexes col_indexes: ColumnIndexes = .all, // Header Alignment header_align: HorizontalAlignment = .center, // Column Alignment col_align: ColumnAlignment = .{ .all = .left }, // Header Borders header_borders: bool = false, // Row Borders //row_borders: bool = false, // Col Borders col_borders: bool = false, }; /// Width Styles for `col_width`. pub const WidthStyle = union(enum) { /// Dynamically calculate Column Widths such that the entire (or most) of the screen is filled horizontally. dynamic_fill, /// Dynamically calculate the Column Width for each Column based on its Header Length and the provided Padding length. dynamic_header_len: u16, /// Statically set all Column Widths to the same value. static_all: u16, /// Statically set individual Column Widths to specific values. static_individual: []const u16, }; /// Column Indexes pub const ColumnIndexes = union(enum) { /// Use all of the Columns. all, /// Use Columns from the specified indexes. by_idx: []const usize, }; /// Header Names pub const HeaderNames = union(enum) { /// Use Field Names as Headers field_names, /// Custom custom: []const []const u8, }; /// Horizontal Alignment pub const HorizontalAlignment = enum { left, center, }; /// Column Alignment pub const ColumnAlignment = union(enum) { all: HorizontalAlignment, by_idx: []const HorizontalAlignment, }; /// Draw a Table for the TUI. pub fn drawTable( /// This should be an ArenaAllocator that can be deinitialized after each event call. /// The Allocator is only used in three cases: /// 1. If a cell is a non-String. (If the Allocator is not provided, those cells will show "[unsupported (TypeName)]".) /// 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.) /// 3. To copy a MultiArrayList into a normal slice. (Note, this is an expensive operation. Prefer to pass a Slice or ArrayList if possible.) alloc: ?mem.Allocator, /// The parent Window to draw to. win: vaxis.Window, /// This must be a Slice, ArrayList, or MultiArrayList. /// Note, MultiArrayList support currently requires allocation. data_list: anytype, // The Table Context for this Table. table_ctx: *TableContext, ) !void { var di_is_mal = false; const data_items = getData: { const DataListT = @TypeOf(data_list); const data_ti = @typeInfo(DataListT); switch (data_ti) { .pointer => |ptr| { if (ptr.size != .slice) return error.UnsupportedTableDataType; break :getData data_list; }, .@"struct" => { const di_fields = meta.fields(DataListT); const al_fields = meta.fields(std.ArrayList([]const u8)); const mal_fields = meta.fields(std.MultiArrayList(struct { a: u8 = 0, b: u32 = 0 })); // Probably an ArrayList const is_al = comptime if (mem.indexOf(u8, @typeName(DataListT), "MultiArrayList") == null and mem.indexOf(u8, @typeName(DataListT), "ArrayList") != null and al_fields.len == di_fields.len) isAL: { var is = true; for (al_fields, di_fields) |al_field, di_field| is = is and mem.eql(u8, al_field.name, di_field.name); break :isAL is; } else false; if (is_al) break :getData data_list.items; // Probably a MultiArrayList const is_mal = if (mem.indexOf(u8, @typeName(DataListT), "MultiArrayList") != null and mal_fields.len == di_fields.len) isMAL: { var is = true; inline for (mal_fields, di_fields) |mal_field, di_field| is = is and mem.eql(u8, mal_field.name, di_field.name); break :isMAL is; } else false; if (!is_mal) return error.UnsupportedTableDataType; if (alloc) |_alloc| { di_is_mal = true; const mal_slice = data_list.slice(); const DataT = dataType: { const fn_info = @typeInfo(@TypeOf(@field(@TypeOf(mal_slice), "get"))); break :dataType fn_info.@"fn".return_type orelse @panic("No Child Type"); }; var data_out_list = std.ArrayList(DataT){}; for (0..mal_slice.len) |idx| try data_out_list.append(_alloc, mal_slice.get(idx)); break :getData try data_out_list.toOwnedSlice(_alloc); } return error.UnsupportedTableDataType; }, else => return error.UnsupportedTableDataType, } }; defer if (di_is_mal) alloc.?.free(data_items); const DataT = @TypeOf(data_items[0]); const fields = meta.fields(DataT); const field_indexes = switch (table_ctx.col_indexes) { .all => comptime allIdx: { var indexes_buf: [fields.len]usize = undefined; for (0..fields.len) |idx| indexes_buf[idx] = idx; const indexes = indexes_buf; break :allIdx indexes[0..]; }, .by_idx => |by_idx| by_idx, }; // Headers for the Table var hdrs_buf: [fields.len][]const u8 = undefined; const headers = hdrs: { switch (table_ctx.header_names) { .field_names => { for (field_indexes) |f_idx| { inline for (fields, 0..) |field, idx| { if (f_idx == idx) hdrs_buf[idx] = field.name; } } break :hdrs hdrs_buf[0..]; }, .custom => |hdrs| break :hdrs hdrs, } }; const table_win = win.child(.{ .y_off = table_ctx.y_off, .width = win.width, .height = win.height, }); // Headers if (table_ctx.col > headers.len - 1) table_ctx.col = @intCast(headers.len - 1); var col_start: u16 = 0; for (headers[0..], 0..) |hdr_txt, idx| { const col_width = try calcColWidth( @intCast(idx), headers, table_ctx.col_width, table_win, ); defer col_start += col_width; const hdr_fg, const hdr_bg = hdrColors: { if (table_ctx.active and idx == table_ctx.col) break :hdrColors .{ table_ctx.active_fg, table_ctx.active_bg } else if (idx % 2 == 0) break :hdrColors .{ .default, table_ctx.hdr_bg_1 } else break :hdrColors .{ .default, table_ctx.hdr_bg_2 }; }; const hdr_win = table_win.child(.{ .x_off = col_start, .y_off = 0, .width = col_width, .height = 1, .border = .{ .where = if (table_ctx.header_borders and idx > 0) .left else .none }, }); var hdr = switch (table_ctx.header_align) { .left => hdr_win, .center => vaxis.widgets.alignment.center(hdr_win, @min(col_width -| 1, hdr_txt.len +| 1), 1), }; hdr_win.fill(.{ .style = .{ .bg = hdr_bg } }); var seg = [_]vaxis.Cell.Segment{.{ .text = if (hdr_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{hdr_txt[0..(col_width -| 4)]}) else hdr_txt, .style = .{ .fg = hdr_fg, .bg = hdr_bg, .bold = true, .ul_style = if (idx == table_ctx.col) .single else .dotted, }, }}; _ = hdr.print(seg[0..], .{ .wrap = .word }); } // Rows if (table_ctx.active_content_fn == null) table_ctx.active_y_off = 0; const max_items: u16 = if (data_items.len > table_win.height -| 1) table_win.height -| 1 else @intCast(data_items.len); var end = table_ctx.start + max_items; if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2) end -|= table_ctx.active_y_off; if (end > data_items.len) end = @intCast(data_items.len); table_ctx.start = tableStart: { if (table_ctx.row == 0) break :tableStart 0; if (table_ctx.row < table_ctx.start) break :tableStart table_ctx.start - (table_ctx.start - table_ctx.row); if (table_ctx.row >= data_items.len - 1) table_ctx.row = @intCast(data_items.len - 1); if (table_ctx.row >= end) break :tableStart table_ctx.start + (table_ctx.row - end + 1); break :tableStart table_ctx.start; }; end = table_ctx.start + max_items; if (table_ctx.row + table_ctx.active_y_off >= win.height -| 2) end -|= table_ctx.active_y_off; if (end > data_items.len) end = @intCast(data_items.len); table_ctx.start = @min(table_ctx.start, end); table_ctx.active_y_off = 0; for (data_items[table_ctx.start..end], 0..) |data, row| { const row_fg, const row_bg = rowColors: { if (table_ctx.active and table_ctx.start + row == table_ctx.row) break :rowColors .{ table_ctx.active_fg, table_ctx.active_bg }; if (table_ctx.sel_rows) |rows| { if (mem.indexOfScalar(u16, rows, @intCast(table_ctx.start + row)) != null) break :rowColors .{ table_ctx.selected_fg, table_ctx.selected_bg }; } if (row % 2 == 0) break :rowColors .{ .default, table_ctx.row_bg_1 }; break :rowColors .{ .default, table_ctx.row_bg_2 }; }; var row_win = table_win.child(.{ .x_off = 0, .y_off = @intCast(1 + row + table_ctx.active_y_off), .width = table_win.width, .height = 1, //.border = .{ .where = if (table_ctx.row_borders) .top else .none }, }); if (table_ctx.start + row == table_ctx.row) { table_ctx.active_y_off = if (table_ctx.active_content_fn) |content| try content(&row_win, table_ctx.active_ctx) else 0; } col_start = 0; const item_fields = meta.fields(DataT); var col_idx: usize = 0; for (field_indexes) |f_idx| { inline for (item_fields[0..], 0..) |item_field, item_idx| contFields: { switch (table_ctx.col_indexes) { .all => {}, .by_idx => { if (item_idx != f_idx) break :contFields; }, } defer col_idx += 1; const col_width = try calcColWidth( item_idx, headers, table_ctx.col_width, table_win, ); defer col_start += col_width; const item = @field(data, item_field.name); const ItemT = @TypeOf(item); const item_win = row_win.child(.{ .x_off = col_start, .y_off = 0, .width = col_width, .height = 1, .border = .{ .where = if (table_ctx.col_borders and col_idx > 0) .left else .none }, }); const item_txt = switch (ItemT) { []const u8 => item, [][]const u8, []const []const u8 => strSlice: { if (alloc) |_alloc| break :strSlice try fmt.allocPrint(_alloc, "{s}", .{item}); break :strSlice item; }, else => nonStr: { switch (@typeInfo(ItemT)) { .@"enum" => break :nonStr @tagName(item), .optional => { const opt_item = item orelse break :nonStr "-"; switch (@typeInfo(ItemT).optional.child) { []const u8 => break :nonStr opt_item, [][]const u8, []const []const u8 => { break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{s}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); }, else => { break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{opt_item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); }, } }, else => { break :nonStr if (alloc) |_alloc| try fmt.allocPrint(_alloc, "{any}", .{item}) else fmt.comptimePrint("[unsupported ({s})]", .{@typeName(DataT)}); }, } }, }; item_win.fill(.{ .style = .{ .bg = row_bg } }); const item_align_win = itemAlignWin: { const col_align = switch (table_ctx.col_align) { .all => |all| all, .by_idx => |aligns| aligns[col_idx], }; break :itemAlignWin switch (col_align) { .left => item_win, .center => center: { const center = vaxis.widgets.alignment.center(item_win, @min(col_width -| 1, item_txt.len +| 1), 1); center.fill(.{ .style = .{ .bg = row_bg } }); break :center center; }, }; }; var seg = [_]vaxis.Cell.Segment{.{ .text = if (item_txt.len > col_width and alloc != null) try fmt.allocPrint(alloc.?, "{s}...", .{item_txt[0..(col_width -| 4)]}) else item_txt, .style = .{ .fg = row_fg, .bg = row_bg }, }}; _ = item_align_win.print(seg[0..], .{ .wrap = .word, .col_offset = table_ctx.cell_x_off }); } } } } /// Calculate the Column Width of `col` using the provided Number of Headers (`num_hdrs`), Width Style (`style`), and Table Window (`table_win`). pub fn calcColWidth( col: u16, headers: []const []const u8, style: WidthStyle, table_win: vaxis.Window, ) !u16 { return switch (style) { .dynamic_fill => dynFill: { var cw: u16 = table_win.width / @as(u16, @intCast(headers.len)); if (cw % 2 != 0) cw +|= 1; while (cw * headers.len < table_win.width - 1) cw +|= 1; break :dynFill cw; }, .dynamic_header_len => dynHdrs: { if (col >= headers.len) break :dynHdrs error.NotEnoughStaticWidthsProvided; break :dynHdrs @as(u16, @intCast(headers[col].len)) + (style.dynamic_header_len * 2); }, .static_all => style.static_all, .static_individual => statInd: { if (col >= headers.len) break :statInd error.NotEnoughStaticWidthsProvided; break :statInd style.static_individual[col]; }, }; }