AI: Produced a writer wrapper to count any invisible bytes necessary for table alignment

This commit is contained in:
Emil Lerch 2026-01-03 17:42:41 -08:00
parent 497b7396dd
commit 528a5b5c56
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 135 additions and 14 deletions

View file

@ -0,0 +1,123 @@
const std = @import("std");
const Format = @import("formatted.zig").Format;
pub const InvisibleByteCountingWriter = struct {
child: *std.Io.Writer,
invisible_bytes: usize = 0,
format: Format,
writer: std.Io.Writer,
pub fn init(child: *std.Io.Writer, format: Format, buffer: []u8) InvisibleByteCountingWriter {
return .{
.child = child,
.format = format,
.writer = .{
.vtable = &.{
.drain = drain,
},
.buffer = buffer,
},
};
}
fn drain(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
const self: *InvisibleByteCountingWriter = @alignCast(@fieldParentPtr("writer", w));
// Count invisible bytes in buffered data
self.invisible_bytes += countInvisible(w.buffered(), self.format);
// Count invisible bytes in incoming data
for (data[0 .. data.len - 1]) |bytes| {
self.invisible_bytes += countInvisible(bytes, self.format);
}
const pattern = data[data.len - 1];
for (0..splat) |_| {
self.invisible_bytes += countInvisible(pattern, self.format);
}
// Pass through to child writer
return self.child.writeSplatHeader(w.buffered(), data, splat);
}
fn countInvisible(bytes: []const u8, format: Format) usize {
var count: usize = 0;
var i: usize = 0;
switch (format) {
.plain_text => {
// Count multi-byte UTF-8 continuation bytes
for (bytes) |byte| {
if (byte & 0xC0 == 0x80) count += 1;
}
return count;
},
.ansi => {
while (i < bytes.len) {
if (bytes[i] == '\x1b' and i + 1 < bytes.len and bytes[i + 1] == '[') {
const start = i;
i += 2;
while (i < bytes.len and bytes[i] != 'm') : (i += 1) {}
if (i < bytes.len) i += 1;
count += i - start;
} else {
// Also count UTF-8 continuation bytes
if (bytes[i] & 0xC0 == 0x80) count += 1;
i += 1;
}
}
},
.html => {
while (i < bytes.len) {
if (bytes[i] == '<') {
const start = i;
while (i < bytes.len and bytes[i] != '>') : (i += 1) {}
if (i < bytes.len) i += 1;
count += i - start;
} else {
// Also count UTF-8 continuation bytes
if (bytes[i] & 0xC0 == 0x80) count += 1;
i += 1;
}
}
},
}
return count;
}
};
test "countInvisible - plain text with UTF-8" {
const testing = std.testing;
try testing.expectEqual(0, InvisibleByteCountingWriter.countInvisible("hello", .plain_text));
try testing.expectEqual(1, InvisibleByteCountingWriter.countInvisible("°C", .plain_text)); // ° is 2 bytes
try testing.expectEqual(2, InvisibleByteCountingWriter.countInvisible("", .plain_text)); // is 3 bytes
try testing.expectEqual(3, InvisibleByteCountingWriter.countInvisible("°C ↑", .plain_text));
}
test "countInvisible - ANSI escape sequences" {
const testing = std.testing;
try testing.expectEqual(0, InvisibleByteCountingWriter.countInvisible("hello", .ansi));
try testing.expectEqual(14, InvisibleByteCountingWriter.countInvisible("\x1b[38;5;82mhello\x1b[0m", .ansi));
try testing.expectEqual(1, InvisibleByteCountingWriter.countInvisible("°C", .ansi)); // UTF-8 still counted
try testing.expectEqual(15, InvisibleByteCountingWriter.countInvisible("\x1b[38;5;82m°C\x1b[0m", .ansi));
}
test "countInvisible - HTML tags" {
const testing = std.testing;
try testing.expectEqual(0, InvisibleByteCountingWriter.countInvisible("hello", .html));
try testing.expectEqual(25, InvisibleByteCountingWriter.countInvisible("<span class=\"c82\">hello</span>", .html));
try testing.expectEqual(1, InvisibleByteCountingWriter.countInvisible("°C", .html)); // UTF-8 still counted
}
test "InvisibleByteCountingWriter - integration" {
const testing = std.testing;
var buf: [64]u8 = undefined;
var fixed = std.Io.Writer.fixed(&buf);
var counting = InvisibleByteCountingWriter.init(&fixed, .ansi, &.{});
try counting.writer.writeAll("test");
try counting.writer.print("\x1b[38;5;82m{d}\x1b[0m", .{42});
try counting.writer.flush();
try testing.expectEqual(14, counting.invisible_bytes); // 14 for ANSI codes only
}

View file

@ -140,6 +140,7 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize,
visibility = 3,
precipitation = 4,
};
const InvisibleByteCountingWriter = @import("InvisibleByteCountingWriter.zig").InvisibleByteCountingWriter;
const art = getWeatherArt(hour.weather_code);
@ -147,14 +148,14 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize,
const art_width = 14; // includes spacer between art and data. This is display width, not actual
var buf: [64]u8 = undefined; // We need more than total_width because total_width is display width, not bytes
var cell_writer = std.Io.Writer.fixed(&buf);
var counting_writer = InvisibleByteCountingWriter.init(&cell_writer, options.format, &.{});
const cw = &counting_writer.writer;
try w.print("{s} ", .{art[line]});
// This will always be positive. This variable represents any hidden bytes
// that will be output based on multi-byte unicode characters, for instance
var display_width_byte_length_offset: usize = 0;
switch (@as(Line, @enumFromInt(line))) {
.condition => {
// we assume the condition string is ascii so bytes == display width
try cell_writer.writeAll(hour.condition);
try cw.writeAll(hour.condition);
},
.temperature => {
const temp = if (options.use_imperial) hour.temp_c * 1.8 + 32 else hour.temp_c;
@ -164,33 +165,30 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize,
const abs_temp = if (temp >= 0) temp else -temp;
const fl_sign: u8 = if (feels_like >= 0) '+' else '-';
const abs_fl = if (feels_like >= 0) feels_like else -feels_like;
// This line includes a degrees character 0x00b0, so we need to
// subtract one from byte length to get the display width
try cell_writer.print("{c}{d:.0}({c}{d:.0}) {s}", .{ sign, abs_temp, fl_sign, abs_fl, temp_unit });
display_width_byte_length_offset = 1;
try cw.print("{c}{d:.0}({c}{d:.0}) {s}", .{ sign, abs_temp, fl_sign, abs_fl, temp_unit });
},
.wind => {
const wind_speed = if (options.use_imperial) hour.windMph() else hour.wind_kph;
const wind_unit = if (options.use_imperial) "mph" else "km/h";
const arrow = degreeToArrow(hour.wind_deg);
try cell_writer.print("{s} {d:.0} {s}", .{ arrow, wind_speed, wind_unit });
display_width_byte_length_offset = arrow.len - 1;
try cw.print("{s} {d:.0} {s}", .{ arrow, wind_speed, wind_unit });
},
.visibility => {
if (hour.visibility_km) |_| {
const visibility = if (options.use_imperial) hour.visiblityMph().? else hour.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km";
try cell_writer.print("{d:.0} {s}", .{ visibility, vis_unit });
try cw.print("{d:.0} {s}", .{ visibility, vis_unit });
}
},
.precipitation => {
const precip = if (options.use_imperial) hour.precip_mm * 0.0393701 else hour.precip_mm;
const precip_unit = if (options.use_imperial) "in" else "mm";
try cell_writer.print("{d:.1} {s} | 0%", .{ precip, precip_unit });
try cw.print("{d:.1} {s} | 0%", .{ precip, precip_unit });
},
}
try cw.flush();
const buffered = cell_writer.buffered();
const display_width = art_width + buffered.len - display_width_byte_length_offset;
const display_width = art_width + buffered.len - counting_writer.invisible_bytes;
try w.writeAll(buffered);
try w.splatByteAll(' ', @max(total_width - display_width, 0));
}