a modern tui library written in zig
at v0.5.0 18 kB view raw
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}