From 2bc2b7dfc2e3305fa9ee99b13c61feaa481a91b2 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 3 Jan 2026 15:23:08 -0800 Subject: [PATCH] AI (partially fixed): Better ANSI rendering --- src/render/ansi.zig | 283 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 253 insertions(+), 30 deletions(-) diff --git a/src/render/ansi.zig b/src/render/ansi.zig index 051c1a4..7505adf 100644 --- a/src/render/ansi.zig +++ b/src/render/ansi.zig @@ -1,43 +1,212 @@ const std = @import("std"); const types = @import("../weather/types.zig"); +pub const Format = enum { + plain_text, + ansi, + html, // TODO: implement +}; + pub const RenderOptions = struct { narrow: bool = false, days: u8 = 3, use_imperial: bool = false, no_caption: bool = false, + format: Format = .ansi, }; pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: RenderOptions) ![]const u8 { - var output: std.ArrayList(u8) = .empty; - errdefer output.deinit(allocator); + var output = std.Io.Writer.Allocating.init(allocator); + defer output.deinit(); - if (!options.no_caption) { - try output.writer(allocator).print("Weather for: {s}\n\n", .{data.location}); + const w = &output.writer; + if (!options.no_caption) + try w.print("Weather report: {s}\n\n", .{data.location}); + + try renderCurrent(w, data.current, options); + + const days_to_show = @min(options.days, data.forecast.len); + if (days_to_show > 0) { + try w.writeAll("\n"); + for (data.forecast[0..days_to_show]) |day| { + try renderForecastDay(w, day, options); + } } - const temp = if (options.use_imperial) data.current.temp_f else data.current.temp_c; + return output.toOwnedSlice(); +} + +fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: RenderOptions) !void { + const temp = if (options.use_imperial) current.temp_f else current.temp_c; + const feels_like = if (options.use_imperial) current.feels_like_f else current.feels_like_c; const temp_unit = if (options.use_imperial) "°F" else "°C"; const wind_unit = if (options.use_imperial) "mph" else "km/h"; - const wind_speed = if (options.use_imperial) data.current.wind_kph * 0.621371 else data.current.wind_kph; + const wind_speed = if (options.use_imperial) current.wind_kph * 0.621371 else current.wind_kph; + const precip_unit = if (options.use_imperial) "in" else "mm"; + const precip = if (options.use_imperial) current.precip_mm * 0.0393701 else current.precip_mm; + const visibility = if (options.use_imperial) "6 mi" else "10 km"; - // Dynamic colors based on temperature and wind speed - const temp_color_code = tempColor(data.current.temp_c); - const wind_color_code = windColor(data.current.wind_kph); - const rain_color = "\x1b[38;5;111m"; // light blue - const cloud_color = "\x1b[38;5;250m"; // gray - const reset = "\x1b[0m"; + const art = getWeatherArt(current.weather_code); + const sign: u8 = if (temp >= 0) '+' else '-'; + 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; - const art = getWeatherArt(data.current.weather_code); - const w = output.writer(allocator); + if (options.format == .plain_text) { + try w.print("{s} {s}\n", .{ art[0], current.condition }); + try w.print("{s} {c}{d:.0}({c}{d:.0}) {s}\n", .{ art[1], sign, abs_temp, fl_sign, abs_fl, temp_unit }); + try w.print("{s} {s} {d:.0} {s}\n", .{ art[2], current.wind_dir, wind_speed, wind_unit }); + try w.print("{s} {s}\n", .{ art[3], visibility }); + try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); + } else { + const temp_color_code = tempColor(current.temp_c); + const wind_color_code = windColor(current.wind_kph); + const cloud_color = "\x1b[38;5;250m"; + const reset = "\x1b[0m"; - try w.print("{s}{s}{s} {s}\n", .{ cloud_color, art[0], reset, data.current.condition }); - try w.print("{s}{s}{s} \x1b[38;5;{d}m{d:.1}{s}{s}\n", .{ cloud_color, art[1], reset, temp_color_code, temp, temp_unit, reset }); - try w.print("{s}{s}{s} {s} \x1b[38;5;{d}m{d:.1}{s} {s}\n", .{ cloud_color, art[2], reset, data.current.wind_dir, wind_color_code, wind_speed, reset, wind_unit }); - try w.print("{s}{s}{s} Humidity: {d}%\n", .{ cloud_color, art[3], reset, data.current.humidity }); - try w.print("{s}{s}{s} {s}{d:.1}{s} mm\n\n", .{ rain_color, art[4], reset, rain_color, data.current.precip_mm, reset }); + try w.print("{s}{s}{s} {s}\n", .{ cloud_color, art[0], reset, current.condition }); + try w.print("{s}{s}{s} \x1b[38;5;{d}m{c}{d:.0}({c}{d:.0}){s} {s}\n", .{ cloud_color, art[1], reset, temp_color_code, sign, abs_temp, fl_sign, abs_fl, reset, temp_unit }); + try w.print("{s}{s}{s} {s} \x1b[38;5;{d}m{d:.0}{s} {s}\n", .{ cloud_color, art[2], reset, current.wind_dir, wind_color_code, wind_speed, reset, wind_unit }); + try w.print("{s}{s}{s} {s}\n", .{ cloud_color, art[3], reset, visibility }); + try w.print("{s}{s}{s} {d:.1} {s}\n", .{ cloud_color, art[4], reset, precip, precip_unit }); + } +} - return output.toOwnedSlice(allocator); +fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderOptions) !void { + var date_str: [11]u8 = undefined; + if (day.hourly.len < 4) { + const max_temp = if (options.use_imperial) day.max_temp_c * 1.8 + 32 else day.max_temp_c; + const min_temp = if (options.use_imperial) day.min_temp_c * 1.8 + 32 else day.min_temp_c; + const temp_unit = if (options.use_imperial) "°F" else "°C"; + const art = getWeatherArt(day.weather_code); + + _ = try formatDate(day.date, .compressed, &date_str); + try w.print("\n{s}\n", .{std.mem.trimEnd(u8, date_str[0..], " ")}); + try w.print("{s} {s}\n", .{ art[0], day.condition }); + try w.print("{s} {d:.0}{s} / {d:.0}{s}\n", .{ art[1], max_temp, temp_unit, min_temp, temp_unit }); + try w.print("{s}\n", .{std.mem.trimRight(u8, art[2], " ")}); + try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); + try w.print("{s}\n", .{std.mem.trimRight(u8, art[4], " ")}); + return; + } + + const formatted_date = try formatDate(day.date, .justified, &date_str); + try w.writeAll(" ┌─────────────┐\n"); + try w.print("┌──────────────────────────────┬───────────────────────┤ {s} ├───────────────────────┬──────────────────────────────┐\n", .{ + std.mem.trimEnd(u8, formatted_date, " "), + }); + try w.writeAll("│ Morning │ Noon └──────┬──────┘ Evening │ Night │\n"); + try w.writeAll("├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤\n"); + + for (0..5) |line| { + try w.writeAll("│ "); + for (day.hourly[0..4], 0..) |hour, i| { + try renderHourlyCell(w, hour, line, options); + if (i < 3) { + try w.writeAll(" │ "); + } else { + try w.writeAll(" │"); + } + } + try w.writeAll("\n"); + } + + try w.writeAll("└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘\n"); +} + +fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize, options: RenderOptions) !void { + const Line = enum(u8) { + condition = 0, + temperature = 1, + wind = 2, + visibility = 3, + precipitation = 4, + }; + + const art = getWeatherArt(hour.weather_code); + + const total_width = 28; + 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); + 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); + }, + .temperature => { + const temp = if (options.use_imperial) hour.temp_c * 1.8 + 32 else hour.temp_c; + const feels_like = if (options.use_imperial) hour.feels_like_c * 1.8 + 32 else hour.feels_like_c; + const temp_unit = if (options.use_imperial) "°F" else "°C"; + const sign: u8 = if (temp >= 0) '+' else '-'; + 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; + }, + .wind => { + const wind_speed = if (options.use_imperial) hour.wind_kph * 0.621371192237 else hour.wind_kph; + const wind_unit = if (options.use_imperial) "mph" else "km/h"; + // Wind direction is two bytes, so we need to subtract one + // Somehow the actual answer here is two, though? + try cell_writer.print("↑ {d:.0} {s}", .{ wind_speed, wind_unit }); + display_width_byte_length_offset = 2; + }, + .visibility => { + const visibility = if (options.use_imperial) "6 mi" else "10 km"; + try cell_writer.print("{s}", .{visibility}); + }, + .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 }); + }, + } + const buffered = cell_writer.buffered(); + const display_width = art_width + buffered.len - display_width_byte_length_offset; + try w.writeAll(buffered); + try w.splatByteAll(' ', @max(total_width - display_width, 0)); +} + +const DateFormat = enum { + justified, + compressed, +}; + +/// The return value from this function will always be exactly 11 characters long, padded at the +/// end with any necessary spaces +fn formatDate(iso_date: []const u8, comptime date_format: DateFormat, date_str_out: []u8) ![]u8 { + const days = [_][]const u8{ "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri" }; + const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + + const year = try std.fmt.parseInt(i32, iso_date[0..4], 10); + const month = try std.fmt.parseInt(u8, iso_date[5..7], 10); + const day = try std.fmt.parseInt(u8, iso_date[8..10], 10); + + var y = year; + var m: i32 = month; + if (m < 3) { + m += 12; + y -= 1; + } + const dow = @mod((day + @divFloor((13 * (m + 1)), 5) + y + @divFloor(y, 4) - @divFloor(y, 100) + @divFloor(y, 400)), 7); + + const day_format = if (date_format == .justified) "{d:>2}" else "{d}"; + const written = try std.fmt.bufPrint( + date_str_out, + "{s} " ++ day_format ++ " {s}", + .{ days[@intCast(dow)], day, months[month - 1] }, + ); + if (written.len < 11) + @memset(date_str_out[written.len..], ' '); + return date_str_out[0..11]; } fn tempColor(temp_c: f32) u8 { @@ -83,17 +252,17 @@ fn windColor(wind_kph: f32) u8 { fn getWeatherArt(code: types.WeatherCode) [5][]const u8 { return switch (@intFromEnum(code)) { 800 => .{ // Clear - " \\ / ", - " .-. ", - " ― ( ) ― ", - " `-' ", - " / \\ ", + " \\ / ", + " .-. ", + " ― ( ) ― ", + " `-' ", + " / \\ ", }, 801, 802 => .{ // Partly cloudy " \\ / ", " _ /\"\".-. ", " \\_( ). ", - " /(___(__) ", + " /(___(__) ", " ", }, 803, 804 => .{ // Cloudy @@ -141,7 +310,7 @@ fn getWeatherArt(code: types.WeatherCode) [5][]const u8 { else => .{ // Unknown " ", " ? ", - " ¯\\_(ツ)_/¯", + " ¯\\_(ツ)_/¯ ", " ", " ", }, @@ -171,7 +340,8 @@ test "render with imperial units" { const output = try render(std.testing.allocator, data, .{ .use_imperial = true }); defer std.testing.allocator.free(output); - try std.testing.expect(std.mem.indexOf(u8, output, "50.0°F") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "+50") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "°F") != null); // 10°C should be color 82 try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[38;5;82m") != null); } @@ -442,8 +612,8 @@ test "temperature matches between ansi and custom format" { const custom_output = try custom.render(std.testing.allocator, data, "%t", true); defer std.testing.allocator.free(custom_output); - // Both should show 55.6°F - try std.testing.expect(std.mem.indexOf(u8, ansi_output, "55.6°F") != null); + // ANSI rounds to integer, custom shows decimal + try std.testing.expect(std.mem.indexOf(u8, ansi_output, "+56") != null); try std.testing.expect(std.mem.indexOf(u8, custom_output, "55.6°F") != null); } @@ -520,3 +690,56 @@ test "windColor returns correct colors for wind speed ranges" { try std.testing.expectEqual(@as(u8, 196), windColor(32)); try std.testing.expectEqual(@as(u8, 196), windColor(50)); } +test "plain text format - MetNo real data" { + const allocator = std.testing.allocator; + const MetNo = @import("../weather/MetNo.zig"); + + const json_data = @embedFile("../tests/metno_test_data.json"); + + const weather_data = try MetNo.parse(undefined, allocator, json_data); + defer weather_data.deinit(); + + const output = try render(allocator, weather_data, .{ .format = .plain_text, .days = 3 }); + defer allocator.free(output); + + const expected = + \\Weather report: 47.6038,-122.3301 + \\ + \\ .-. Light rain + \\ ( ). +7(+7) °C + \\ (___(__) E 6 km/h + \\ ʻ ʻ ʻ ʻ 10 km + \\ ʻ ʻ ʻ ʻ 0.0 mm + \\ + \\ + \\Fri 2 Jan + \\ .-. Rain + \\ ( ). 7°C / 7°C + \\ (___(__) + \\ ʻ ʻ ʻ ʻ + \\ ʻ ʻ ʻ ʻ + \\ ┌─────────────┐ + \\┌──────────────────────────────┬───────────────────────┤ Sat 3 Jan ├───────────────────────┬──────────────────────────────┐ + \\│ Morning │ Noon └──────┬──────┘ Evening │ Night │ + \\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ + \\│ .-. Rain │ Cloudy │ .-. Heavy rain │ \ / Partly cloudy │ + \\│ ( ). +7(+7) °C │ .--. +6(+6) °C │ ( ). +7(+7) °C │ _ /"".-. +8(+8) °C │ + \\│ (___(__) ↑ 5 km/h │ .-( ). ↑ 9 km/h │ (___(__) ↑ 14 km/h │ \_( ). ↑ 12 km/h │ + \\│ ʻ ʻ ʻ ʻ 10 km │ (___.__)__) 10 km │ ʻ ʻ ʻ ʻ 10 km │ /(___(__) 10 km │ + \\│ ʻ ʻ ʻ ʻ 0.3 mm | 0% │ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 1.2 mm | 0% │ 0.0 mm | 0% │ + \\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ + \\ ┌─────────────┐ + \\┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐ + \\│ Morning │ Noon └──────┬──────┘ Evening │ Night │ + \\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ + \\│ Cloudy │ Cloudy │ Cloudy │ .-. Light rain │ + \\│ .--. +10(+10) °C │ .--. +8(+8) °C │ .--. +10(+10) °C │ ( ). +9(+9) °C │ + \\│ .-( ). ↑ 7 km/h │ .-( ). ↑ 14 km/h │ .-( ). ↑ 31 km/h │ (___(__) ↑ 24 km/h │ + \\│ (___.__)__) 10 km │ (___.__)__) 10 km │ (___.__)__) 10 km │ ʻ ʻ ʻ ʻ 10 km │ + \\│ 0.0 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 0.2 mm | 0% │ + \\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ + \\ + ; + + try std.testing.expectEqualStrings(expected, output); +}