From 528a5b5c5648898f66f11b850fb164ba7005b51b Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 3 Jan 2026 17:42:41 -0800 Subject: [PATCH] AI: Produced a writer wrapper to count any invisible bytes necessary for table alignment --- src/render/InvisibleByteCountingWriter.zig | 123 +++++++++++++++++++++ src/render/formatted.zig | 26 ++--- 2 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 src/render/InvisibleByteCountingWriter.zig diff --git a/src/render/InvisibleByteCountingWriter.zig b/src/render/InvisibleByteCountingWriter.zig new file mode 100644 index 0000000..bfeb830 --- /dev/null +++ b/src/render/InvisibleByteCountingWriter.zig @@ -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("hello", .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 +} diff --git a/src/render/formatted.zig b/src/render/formatted.zig index 86fdf7c..c0f135c 100644 --- a/src/render/formatted.zig +++ b/src/render/formatted.zig @@ -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)); }