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