Allocation Free TUI Tables

Sorry for the noob question, I’m sure most people have already done this and figured it out, but I’ve always let libraries do it for me until now. I’m quickly realizing why Zig doesn’t have a native string type :sweat_smile:

I’m porting a CLI project over from Go, and I need a really simple table:

┌──────────────────────────────┬──────┐
│          DIRECTORY           │ PATH │
├──────────────────────────────┼──────┤
│ /home/username/Desktop/envr/ │ .env │
└──────────────────────────────┴──────┘

I was able to get this working, but the code I wrote feels…far from correct. The function is called structs, it accepts a list of structs, a writer, and optionally a subset of struct fields to print (by default it prints all of them:

test "can print a table with limited columns" {
    const gpa = std.testing.allocator;

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();

    const F = struct { foo: []const u8, bar: []const u8 };
    const rows: [1]F = .{.{ .foo = "bat", .bar = "baz" }};

    // I realize @constCast is a code smell, I'll fix it later
    try structs(F, @constCast(&rows), .{ .out = &out.writer, .fields = .initOne(.foo) });

    const got = try out.toOwnedSlice();
    defer gpa.free(got);

    try std.testing.expectEqualStrings(
        \\┌─────┐
        \\│ foo │
        \\├─────┤
        \\│ bat │
        \\└─────┘
        \\
    , got);
}

I basically just defer to writer for everything, and while it works, code like this feels inelegant, especially when called inside of an inline for loop:

const padding = max_column_widths[i] + 2;
for (0..padding) |_| {
    _ = try writer.write("─");
}

I would like to try and do this without allocations, but have found that to pretty difficult (if I deviate from my working code). Even if I assign a line buffer, and break every column up into slices perfectly, the characters I’ve used to draw the lines take up more than one byte, making the math awkward.

Any advice on how the zig zen way to do this would be appreciated :slight_smile:

  • Only one obvious way to do things.

Here is the full code

tabula.zig

const std = @import("std");

// Could be an enum? or tagged union.
const hor = "─"; // Horizontal
const tl = "┌";  // Top Left
const tm = "┬";  // Top Middle
const tr = "┐";  // Top Right
const sep = "│"; // Separator
const ml = "├";  // Middle Left
const mm = "┼";  // Middle Middle (Center)
const mr = "┤";  // Middle Right
const bl = "└";  // Bottom Left
const bm = "┴";  // Bottom Middle
const br = "┘";  // Bottom Right


/// Print a list of structs as a table to opts.out.
pub fn structs(
    comptime T: type,
    items: []T,
    opts: struct {
        out: *std.Io.Writer, // TODO: Default to stdout.
        fields: std.EnumSet(std.meta.FieldEnum(T)) = .full,
    },
) !void {
    const writer = opts.out;
    const max_column_widths = determine_col_widths(T, items);

    try header(T, opts.fields, &max_column_widths, opts.out);

    // Print body
    for (items) |item| {
        _ = try writer.write(sep);

        // TODO: Always loops through every field
        const all_fields = @typeInfo(T).@"struct".fields;
        inline for (all_fields) |field| {
            var itr = opts.fields.iterator();
            var i: usize = 0;
            while (itr.next()) |c| : (i += 1) {
                if (std.mem.eql(u8, @tagName(c), field.name)) {
                    _ = try writer.write(" ");
                    try write_aligned(writer, @field(item, field.name), max_column_widths[i], .left);
                    try writer.print(" {s}", .{sep});

                    break;
                }
            }
        }

        _ = try writer.write("\n");
    }

    // Print post-body
    {
        _ = try writer.write(bl);

        var itr = opts.fields.iterator();
        var i: usize = 0;
        while (itr.next()) |_| : (i += 1) {
            if (i > 0) {
                _ = try writer.write(bm);
            }

            const padding = max_column_widths[i] + 2;
            for (0..padding) |_| {
                _ = try writer.write(hor);
            }
        }

        _ = try writer.write(br);
        _ = try writer.write("\n");
    }
}

fn determine_col_widths(
    T: type,
    items: []T,
) [@typeInfo(T).@"struct".fields.len]usize {
    const all_fields = @typeInfo(T).@"struct".fields;

    var max_column_widths: [all_fields.len]usize = @splat(0);
    for (items) |item| {
        inline for (all_fields, 0..) |field, i| {
            // TODO: Get str len of item
            const value_len = @field(item, field.name).len;
            max_column_widths[i] = @max(
                max_column_widths[i],
                field.name.len,
                value_len,
            );
        }
    }

    return max_column_widths;
}

// Print the header of a table
fn header(
    T: type,
    fields: std.EnumSet(std.meta.FieldEnum(T)),
    max_column_widths: []const usize,
    writer: *std.Io.Writer,
) !void {

    // Print Pre-Header
    {
        _ = try writer.write(tl);

        var itr = fields.iterator();
        var i: usize = 0;
        while (itr.next()) |_| : (i += 1) {
            if (i > 0) {
                _ = try writer.write(tm);
            }
            const padding = max_column_widths[i] + 2;
            for (0..padding) |_| {
                _ = try writer.write(hor);
            }
        }

        _ = try writer.write(tr ++ "\n");
    }

    // Main Header
    {
        _ = try writer.write(sep);

        var itr = fields.iterator();
        var i: usize = 0;
        while (itr.next()) |field| : (i += 1) {
            _ = try writer.write(" ");
            try write_aligned(
                writer,
                @tagName(field),
                max_column_widths[i],
                .center,
            );
            try writer.print(" {s}", .{sep});
        }

        try writer.print("\n", .{});
    }

    // Print post-header
    {
        _ = try writer.write(ml);

        var itr = fields.iterator();
        var i: usize = 0;
        while (itr.next()) |_| : (i += 1) {
            if (i > 0) {
                _ = try writer.write(mm);
            }
            const padding = max_column_widths[i] + 2;
            for (0..padding) |_| {
                _ = try writer.write(hor);
            }
        }

        _ = try writer.write(mr ++ "\n");
    }
}

fn write_aligned(
    writer: *std.Io.Writer,
    data: []const u8,
    max_width: usize,
    alignment: Alignment,
) !void {
    const padding: [2]usize = switch (alignment) {
        .left => .{ 0, max_width - data.len },
        .right => .{ max_width - data.len, 0 },
        .center => blk: {
            // Faster to inline the divFloor?
            const half = @divFloor(max_width - data.len, 2);
            break :blk .{ half, max_width - data.len - half };
        },
    };

    for (0..padding[0]) |_| {
        _ = try writer.write(" ");
    }

    _ = try writer.write(data);

    for (0..padding[1]) |_| {
        _ = try writer.write(" ");
    }
}

const Alignment = enum { left, center, right };

I did manage to rewrite write_aligned in a form that didn’t require a writer anymore

fn align_text(data: []const u8, buf: []u8, alignment: Alignment) void {
    const dest = switch (alignment) {
        .left => buf[0..data.len],
        .right => buf[buf.len - data.len .. buf.len],
        .center => blk: {
            const start = (buf.len - data.len) / 2;

            break :blk buf[start .. start + data.len];
        },
    };

    @memcpy(dest, data);
}

But this necessitated adding a (fixed) buffer, which while probably fine for my small tables, could potentially run out with long path names.

pub fn structs(
    comptime T: type,
    items: []T,
    opts: struct {
        out: *std.Io.Writer, // TODO: Default stdout.
        fields: std.EnumSet(std.meta.FieldEnum(T)) = .full,
    },
) error{WriteFailed}!void {
    const writer = opts.out;
    const max_column_widths = determine_col_widths(T, items);
    var buf: [255]u8 = undefined;

    // Print body
    for (items) |item| {
        _ = try writer.write(sep);
 
        const all_fields = @typeInfo(T).@"struct".fields;
        inline for (all_fields) |field| {
            var itr = opts.fields.iterator();
            var i: usize = 0;
            while (itr.next()) |c| : (i += 1) {
                if (std.mem.eql(u8, @tagName(c), field.name)) {
                    const col = buf[0..max_column_widths[i]];
                    @memset(col, ' ');

                    align_text(@field(item, field.name), col, .left);

                    try writer.print(" {s} {s}", .{ col, sep });

                    break;
                }
            }
        }

        _ = try writer.write("\n");
    }
    
    // ...
}

If there’s a way to loop through just the selected fields at comptime rather than having to loop + check all of them, that would also be great.

// TODO: Always loops through every field
const all_fields = @typeInfo(T).@"struct".fields;
inline for (all_fields) |field| {
    var itr = opts.fields.iterator();
    var i: usize = 0;
    while (itr.next()) |c| : (i += 1) {
    if (std.mem.eql(u8, @tagName(c), field.name)) {
        _ = try writer.write(" ");
        try write_aligned(
            writer,
            @field(item, field.name),
            max_column_widths[i],
            .left,
        );
        try writer.print(" {s}", .{sep});

        break;
    }
}
1 Like

For strings with lengths unknown at compile time, using an allocator is quite reasonable. Although the strings in your example are known at compile time, in actual use there will inevitably be a large amount of content that is only known at runtime. One should not deliberately avoid allocation for this reason, especially under the abstraction of writer, where in fact allocation or not is also user-configurable.

I don’t think there is a major problem with your original implementation idea. However, regarding the function signature, items: []T, here should be items: []const T, (the same applies to the function signature of determine_col_widths) because there are no modifications to items within the entire function.

In addition, I hope the writer does not default to stdout. I can’t think of any benefit in implicitly defaulting to stdout; no one wants the output table to be printed to stdout by default.

In my eyes, this is a significant regression. Although using buffers is not inadvisable, it is obvious that hard-coded buffer sizes are absolutely unacceptable. Even when using buffers, they should be allocated with a runtime-determined size. Clearly, this makes the code logic more complex and fails to reuse the writer abstraction.

2 Likes

Thank you for your time and input!

Perhaps it is better (and more idiomatic) to inject stdout at the top and trickle it all thr way down. However if its a mandatory parameter, then perhaps it deserves its own slot in the function definition, rather than tucked away in a struct.

And thanks for solving my `@constCast` issue, still getting used to wrangling that.

1 Like

items: []const T

1 Like

You could separate this into a generic function producing a type and that type having a format function:

pub fn Structs(comptime T: type) type {
    return struct {
        items: []const T,
        fields: std.EnumSet(std.meta.FieldEnum(T)) = .full,

        pub fn format(self:@This(), writer:*std.Io.Writer) !void {
            // ...
        }
    };
}

Why? I think using a writer would be better…
I think using fixed size buffers for things you can calculate and directly write to a writer is bad, because it adds unnecessary intermediary restrictions and potential code bloat (for example when these buffers are poorly used which is likely because we already have the writer which can handle buffering).

You can move the fields to be a comptime parameter:

pub const Options = struct {
    fields: std.EnumSet(std.meta.FieldEnum(T)) = .full,
};
pub fn Structs(comptime T: type, comptime options:Options) type {
    return struct {
        items: []const T,
        ...
1 Like

Thank you both for your insight!

Because I am new to working at this level (close to the cpu). It felt more intuitive and performant to align the bytes inside the column buffer using @memcopyand @memset, than to make a bunch of extra function calls with the writer. Probably also influenced by functional programming training scars :sweat_smile:

It sounds like my initial idea wasn’t terrible, and if I want to use a buffer, I should use an ArrayList.

Just use a format function and pass it a writer like std.Io.Writer.Allocating, it can be used as a writer (and is similar-ish to an ArrayList(u8)) and then you can do one of these things:

  • access the part that was accumulated in memory by using aw.written()
  • convert it to an ArrayList with aw.toArrayList()
  • use aw.toOwnedSlice()
  • reset it with aw.clearRetainingCapacity() and use it for another task
  • pass &aw.writer to functions expecting a writer

And alternatively if your code that needs to use the format function already has a fixed buffer available to it: std.Io.Writer.fixed (but I would avoid adding lots of hardcoded fixed buffers, especially if you don’t have a good way to find the size needed for it)

I do like separating the type from the writing, I feel that that’s cleaner, however, it seems I’m still SOL with the comptime loop :’(

/// Prepare a TUI table to be written to a writer.
pub fn Table(comptime T: type) type {
    return struct {
        items: []const T,
        comptime fields: std.EnumSet(std.meta.FieldEnum(T)) = .full,

        pub fn format(self: @This(), writer: *std.Io.Writer) !void {
            const max_column_widths = determine_col_widths(T, self.items);

            try header(T, self.fields, &max_column_widths, writer);

            // Print body
            for (self.items) |item| {
                _ = try writer.write(sep);

                // const all_fields = @typeInfo(T).@"struct".fields;
                // inline for (all_fields) |field| {
                var itr = self.fields.iterator();
                var i: usize = 0;
                while (itr.next()) |c| : (i += 1) {
                    // if (std.mem.eql(u8, @tagName(c), field.name)) {
                    _ = try writer.write(" ");
                    // try write_aligned(writer, @field(item, field.name), max_column_widths[i], .left);
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                    try writer.print(" {s}", .{sep});

                    break;
                    // }
                }
                // }

                _ = try writer.write("\n");
            }

            // Print post-body
            {
                _ = try writer.write(bl);

                var itr = self.fields.iterator();
                var i: usize = 0;
                while (itr.next()) |_| : (i += 1) {
                    if (i > 0) {
                        _ = try writer.write(bm);
                    }

                    const padding = max_column_widths[i] + 2;
                    for (0..padding) |_| {
                        _ = try writer.write(hor);
                    }
                }

                _ = try writer.write(br);
                _ = try writer.write("\n");
            }
        }
    };
}
zig build test
test
└─ run test
   └─ compile test Debug native 5 errors
src/tabula.zig:33:38: error: runtime value contains reference to comptime var
                var itr = self.fields.iterator();
                          ~~~~~~~~~~~^~~~~~~~~
src/tabula.zig:33:38: note: comptime var pointers are not available at runtime
src/tabula.zig:33:38: note: 'runtime_value' points to comptime field
referenced by:
    printValue__anon_51379: deps/zig/lib/std/Io/Writer.zig:1077:39
    print__anon_51306: deps/zig/lib/std/Io/Writer.zig:719:25
    2 reference(s) hidden; use '-freference-trace=4' to see all references
src/tabula.zig:33:38: error: runtime value contains reference to comptime var
                var itr = self.fields.iterator();
                          ~~~~~~~~~~~^~~~~~~~~
src/tabula.zig:33:38: note: comptime var pointers are not available at runtime
src/tabula.zig:33:38: note: 'runtime_value' points to comptime field
src/tabula.zig:33:38: error: runtime value contains reference to comptime var
                var itr = self.fields.iterator();
                          ~~~~~~~~~~~^~~~~~~~~
src/tabula.zig:33:38: note: comptime var pointers are not available at runtime
src/tabula.zig:33:38: note: 'runtime_value' points to comptime field
src/tabula.zig:33:38: error: runtime value contains reference to comptime var
                var itr = self.fields.iterator();
                          ~~~~~~~~~~~^~~~~~~~~
src/tabula.zig:33:38: note: comptime var pointers are not available at runtime
src/tabula.zig:33:38: note: 'runtime_value' points to comptime field
src/tabula.zig:305:27: error: cannot store runtime value in compile time variable
        .fields = .initOne(.foo),
                  ~~~~~~~~^~~~~~

Don’t use comptime fields and especially don’t mix types you want to use for comptime programming with types you want to use at runtime.

Make the fields part of the parameters to Table like I showed in my example.

It seems I need a way to get inline for over fields rather than an iterator.

/// Prepare a TUI table to be written to a writer.
pub fn Table(
    comptime T: type,
    comptime fields: std.EnumSet(std.meta.FieldEnum(T)),
) type {
    return struct {
        items: []const T,

        pub fn format(self: @This(), writer: *std.Io.Writer) !void {
            const max_column_widths = determine_col_widths(T, self.items);

            try header(T, fields, &max_column_widths, writer);

            // Print body
            for (self.items) |item| {
                _ = try writer.write(sep);

                // const all_fields = @typeInfo(T).@"struct".fields;
                // inline for (all_fields) |field| {
                var itr = fields.iterator();
                var i: usize = 0;
                while (itr.next()) |c| : (i += 1) {
                    // if (std.mem.eql(u8, @tagName(c), field.name)) {
                    _ = try writer.write(" ");
                    // try write_aligned(writer, @field(item, field.name), max_column_widths[i], .left);
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                    try writer.print(" {s}", .{sep});

                    break;
                    // }
                }
                // }

                _ = try writer.write("\n");
            }

            // Print post-body
            {
                _ = try writer.write(bl);

                var itr = self.fields.iterator();
                var i: usize = 0;
                while (itr.next()) |_| : (i += 1) {
                    if (i > 0) {
                        _ = try writer.write(bm);
                    }

                    const padding = max_column_widths[i] + 2;
                    for (0..padding) |_| {
                        _ = try writer.write(hor);
                    }
                }

                _ = try writer.write(br);
                _ = try writer.write("\n");
            }
        }
    };
}

zig build test
test
└─ run test
   └─ compile test Debug native 5 errors
tabula.zig:41:60: error: unable to evaluate comptime expression
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                           ^~~~~~~~~~~
tabula.zig:41:69: note: operation is runtime due to this operand
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                                    ^
tabula.zig:41:60: note: field name must be comptime-known
referenced by:
    printValue__anon_51362: /home/spencer/github.com/envr-zig/deps/zig/lib/std/Io/Writer.zig:1077:39
    print__anon_51291: /home/spencer/github.com/envr-zig/deps/zig/lib/std/Io/Writer.zig:719:25
    2 reference(s) hidden; use '-freference-trace=4' to see all references
tabula.zig:41:60: error: unable to evaluate comptime expression
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                           ^~~~~~~~~~~
tabula.zig:41:69: note: operation is runtime due to this operand
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                                    ^
tabula.zig:41:60: note: field name must be comptime-known
tabula.zig:41:60: error: unable to evaluate comptime expression
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                           ^~~~~~~~~~~
tabula.zig:41:69: note: operation is runtime due to this operand
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                                    ^
tabula.zig:41:60: note: field name must be comptime-known
tabula.zig:41:60: error: unable to evaluate comptime expression
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                           ^~~~~~~~~~~
tabula.zig:41:69: note: operation is runtime due to this operand
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                                    ^
tabula.zig:41:60: note: field name must be comptime-known
tabula.zig:41:60: error: unable to evaluate comptime expression
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                           ^~~~~~~~~~~
tabula.zig:41:69: note: operation is runtime due to this operand
                    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
                                                                    ^
tabula.zig:41:60: note: field name must be comptime-known

If you want to iterate over all fields you could do something like this:

comptime var column_index = 0;
const all_fields = @typeInfo(std.meta.FieldEnum(T)).@"enum".fields;
inline for (all_fields) |field| {
    if(comptime fields.contains(field.value)) {
        _ = try writer.write(" ");
        // try write_aligned(writer, @field(item, field.name), max_column_widths[column_index], .left);
        try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[column_index], .left);
        try writer.print(" {s}", .{sep});
        column_index += 1;
    }
}

Alternatively you could use:

comptime var itr = fields.iterator();
comptime var i: usize = 0;
inline while (itr.next()) |c| : (i += 1) {
    _ = try writer.write(" ");
    // try write_aligned(writer, @field(item, field.name), max_column_widths[i], .left);
    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
    try writer.print(" {s}", .{sep});
}

Unfortunately, no matter what way I slice it, the compiler rejects it.

zig build test
test
└─ run test
   └─ compile test Debug native 5 errors
src/tabula.zig:111:26: error: runtime value contains reference to comptime var
        inline while (itr.next()) |_| : (i += 1) {
                      ~~~^~~~~
src/tabula.zig:111:26: note: comptime var pointers are not available at runtime
src/tabula.zig:109:43: note: 'runtime_value' points to comptime var declared here
        comptime var itr = fields.iterator();
                           ~~~~~~~~~~~~~~~^~

Why do you use the iterator if you don’t use its value?
If you just need the index I would use inline for(0..fields.count())

Can you show the code?

Thank your for your intellegent insight. You are indeed correct in that 2 of the loops in the header function don’t need the iterator, they can be simplified with an inline for loop. for the others, the code you suggested was missing a comptime in front of itr.next(). This let me get rid of all unnecessary loop steps! Thank you :folded_hands:

comptime var itr = fields.iterator();
comptime var i: usize = 0;
inline while (comptime itr.next()) |c| : (i += 1) {
    _ = try writer.write(" ");
    try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left);
    try writer.print(" {s}", .{sep});
}
1 Like