From a314fc39d434b035842f021c490f4dbbe10469dc Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 3 Jan 2026 23:48:35 -0800 Subject: [PATCH] aligned ansi/initial html implementation --- src/render/InvisibleByteCountingWriter.zig | 123 --------- src/render/formatted.zig | 284 +++++++++++++++++---- src/tests/metno-phoenix-tmp.ansi | 38 +++ src/weather/types.zig | 6 + 4 files changed, 283 insertions(+), 168 deletions(-) delete mode 100644 src/render/InvisibleByteCountingWriter.zig create mode 100644 src/tests/metno-phoenix-tmp.ansi diff --git a/src/render/InvisibleByteCountingWriter.zig b/src/render/InvisibleByteCountingWriter.zig deleted file mode 100644 index bfeb830..0000000 --- a/src/render/InvisibleByteCountingWriter.zig +++ /dev/null @@ -1,123 +0,0 @@ -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 66949cb..1d38a13 100644 --- a/src/render/formatted.zig +++ b/src/render/formatted.zig @@ -11,9 +11,47 @@ fn degreeToArrow(deg: f32) []const u8 { pub const Format = enum { plain_text, ansi, - html, // TODO: implement + html, }; +fn countInvisible(bytes: []const u8, format: Format) usize { + var count: usize = 0; + var i: usize = 0; + + switch (format) { + .plain_text => for (bytes) |byte| { // Count multi-byte UTF-8 continuation bytes + if (byte & 0xC0 == 0x80) count += 1; + }, + .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; +} + pub const RenderOptions = struct { narrow: bool = false, days: u8 = 3, @@ -54,47 +92,67 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re const art = getWeatherArt(current.weather_code); const sign: u8 = if (temp >= 0) '+' else '-'; - const abs_temp = if (temp >= 0) temp else -temp; + const abs_temp = @abs(temp); const fl_sign: u8 = if (feels_like >= 0) '+' else '-'; - const abs_fl = if (feels_like >= 0) feels_like else -feels_like; + const abs_fl = @abs(feels_like); - 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], degreeToArrow(current.wind_deg), wind_speed, wind_unit }); - if (current.visibility_km) |_| { - const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; - const vis_unit = if (options.use_imperial) "mi" else "km"; - try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); - } else { - try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); - } - 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"; + switch (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], degreeToArrow(current.wind_deg), wind_speed, wind_unit }); + if (current.visibility_km) |_| { + const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; + const vis_unit = if (options.use_imperial) "mi" else "km"; + try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); + } else { + try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); + } + try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); + }, + .ansi => { + 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, 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, degreeToArrow(current.wind_deg), wind_color_code, wind_speed, reset, wind_unit }); - if (current.visibility_km) |_| { - const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; - const vis_unit = if (options.use_imperial) "mi" else "km"; - try w.print("{s}{s}{s} {d:.0} {s}\n", .{ cloud_color, art[3], reset, visibility, vis_unit }); - } else { - try w.print("{s}{s}{s}\n", .{ cloud_color, std.mem.trimRight(u8, art[3], " "), reset }); - } - try w.print("{s}{s}{s} {d:.1} {s}\n", .{ cloud_color, art[4], reset, precip, precip_unit }); + 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, degreeToArrow(current.wind_deg), wind_color_code, wind_speed, reset, wind_unit }); + if (current.visibility_km) |_| { + const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; + const vis_unit = if (options.use_imperial) "mi" else "km"; + try w.print("{s}{s}{s} {d:.0} {s}\n", .{ cloud_color, art[3], reset, visibility, vis_unit }); + } else { + try w.print("{s}{s}{s}\n", .{ cloud_color, std.mem.trimRight(u8, art[3], " "), reset }); + } + try w.print("{s}{s}{s} {d:.1} {s}\n", .{ cloud_color, art[4], reset, precip, precip_unit }); + }, + .html => { + const temp_color = ansiToHex(tempColor(current.temp_c)); + const wind_color = ansiToHex(windColor(current.wind_kph)); + const cloud_color = "#bcbcbc"; // 250 + + try w.print("{s} {s}\n", .{ cloud_color, art[0], current.condition }); + try w.print("{s} {c}{d:.0}({c}{d:.0}) {s}\n", .{ cloud_color, art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit }); + try w.print("{s} {s} {d:.0} {s}\n", .{ cloud_color, art[2], degreeToArrow(current.wind_deg), wind_color, wind_speed, wind_unit }); + if (current.visibility_km) |_| { + const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; + const vis_unit = if (options.use_imperial) "mi" else "km"; + try w.print("{s} {d:.0} {s}\n", .{ cloud_color, art[3], visibility, vis_unit }); + } else { + try w.print("{s}\n", .{ cloud_color, std.mem.trimRight(u8, art[3], " ") }); + } + try w.print("{s} {d:.1} {s}\n", .{ cloud_color, art[4], precip, precip_unit }); + }, } } 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 max_temp = if (options.use_imperial) day.maxTempFahrenheit() else day.max_temp_c; + const min_temp = if (options.use_imperial) day.minTempFahrenheit() else day.min_temp_c; const temp_unit = if (options.use_imperial) "°F" else "°C"; const art = getWeatherArt(day.weather_code); @@ -140,7 +198,6 @@ 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); @@ -148,30 +205,77 @@ 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; + const cw = &cell_writer; - try w.print("{s} ", .{art[line]}); + switch (options.format) { + .ansi => { + try w.print("\x1b[38;5;250m{s}\x1b[0m ", .{art[line]}); + }, + .html => { + try w.print("{s} ", .{art[line]}); + }, + .plain_text => { + try w.print("{s} ", .{art[line]}); + }, + } switch (@as(Line, @enumFromInt(line))) { .condition => { try cw.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 = if (options.use_imperial) hour.tempFahrenheit() else hour.temp_c; + const feels_like = if (options.use_imperial) hour.feelsLikeFahrenheit() 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 abs_temp = @abs(temp); const fl_sign: u8 = if (feels_like >= 0) '+' else '-'; - const abs_fl = if (feels_like >= 0) feels_like else -feels_like; - try cw.print("{c}{d:.0}({c}{d:.0}) {s}", .{ sign, abs_temp, fl_sign, abs_fl, temp_unit }); + const abs_fl = @abs(feels_like); + switch (options.format) { + .ansi => { + const color = tempColor(hour.temp_c); + try cw.print("\x1b[38;5;{d}m{c}{d:.0}({c}{d:.0})\x1b[0m {s}", .{ + color, + sign, + abs_temp, + fl_sign, + abs_fl, + temp_unit, + }); + }, + .html => { + const color = ansiToHex(tempColor(hour.temp_c)); + try cw.print("{c}{d:.0}({c}{d:.0}) {s}", .{ + color, + sign, + abs_temp, + fl_sign, + abs_fl, + temp_unit, + }); + }, + .plain_text => { + 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 cw.print("{s} {d:.0} {s}", .{ arrow, wind_speed, wind_unit }); + switch (options.format) { + .ansi => { + const color = windColor(hour.wind_kph); + try cw.print("{s} \x1b[38;5;{d}m{d:.0}\x1b[0m {s}", .{ arrow, color, wind_speed, wind_unit }); + }, + .html => { + const color = ansiToHex(windColor(hour.wind_kph)); + try cw.print("{s} {d:.0} {s}", .{ arrow, color, wind_speed, wind_unit }); + }, + .plain_text => { + try cw.print("{s} {d:.0} {s}", .{ arrow, wind_speed, wind_unit }); + }, + } }, .visibility => { if (hour.visibility_km) |_| { @@ -188,9 +292,13 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize, } try cw.flush(); const buffered = cell_writer.buffered(); - const display_width = art_width + buffered.len - counting_writer.invisible_bytes; + const invisible = countInvisible(buffered, options.format); + const display_width = art_width + buffered.len - invisible; try w.writeAll(buffered); - try w.splatByteAll(' ', @max(@as(isize, total_width) - @as(isize, @intCast(display_width)), 0)); + try w.splatByteAll( + ' ', + @max(@as(isize, @intCast(total_width)) - @as(isize, @intCast(display_width)), 0), + ); } const DateFormat = enum { @@ -267,6 +375,39 @@ fn windColor(wind_kph: f32) u8 { return 196; } +fn ansiToHex(code: u8) []const u8 { + return switch (code) { + 21 => "#0000ff", + 27 => "#005fff", + 33 => "#0087ff", + 39 => "#00afff", + 45 => "#00d7ff", + 46 => "#00ffff", + 47 => "#00ffd7", + 48 => "#00ffaf", + 49 => "#00ff87", + 50 => "#00ff5f", + 51 => "#00ff00", + 82 => "#5fff00", + 118 => "#87ff00", + 154 => "#afff00", + 190 => "#d7ff00", + 196 => "#ff0000", + 202 => "#ff5f00", + 208 => "#ff8700", + 214 => "#ffaf00", + 220 => "#ffd700", + 226 => "#ffff00", + 241 => "#626262", + 242 => "#6c6c6c", + 243 => "#767676", + 246 => "#949494", + 250 => "#bcbcbc", + 253 => "#dadada", + else => "#ffffff", + }; +} + fn getWeatherArt(code: types.WeatherCode) [5][]const u8 { return switch (@intFromEnum(code)) { 800 => .{ // Clear @@ -750,3 +891,56 @@ test "plain text format - MetNo real data" { try std.testing.expectEqualStrings(expected, output); } +test "ansi format - MetNo real data - phoenix" { + const allocator = std.testing.allocator; + const MetNo = @import("../weather/MetNo.zig"); + + const json_data = @embedFile("../tests/metno-phoenix.json"); + + const weather_data = try MetNo.parse(undefined, allocator, json_data); + defer weather_data.deinit(); + + const output = try render(allocator, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true }); + defer allocator.free(output); + + // const file = try std.fs.cwd().createFile("/tmp/formatted_output.txt", .{}); + // defer file.close(); + // try file.writeAll(output); + + const expected = @embedFile("../tests/metno-phoenix-tmp.ansi"); + + try std.testing.expectEqualStrings(expected, output); +} + +test "countInvisible - plain text with UTF-8" { + const testing = std.testing; + try testing.expectEqual(0, countInvisible("hello", .plain_text)); + try testing.expectEqual(1, countInvisible("°C", .plain_text)); // ° is 2 bytes + try testing.expectEqual(2, countInvisible("↑", .plain_text)); // ↑ is 3 bytes + try testing.expectEqual(3, countInvisible("°C ↑", .plain_text)); +} + +test "countInvisible - ANSI escape sequences" { + const testing = std.testing; + try testing.expectEqual(0, countInvisible("hello", .ansi)); + try testing.expectEqual(14, countInvisible("\x1b[38;5;82mhello\x1b[0m", .ansi)); + try testing.expectEqual(1, countInvisible("°C", .ansi)); // UTF-8 still counted + try testing.expectEqual(15, countInvisible("\x1b[38;5;82m°C\x1b[0m", .ansi)); +} + +test "countInvisible - HTML tags" { + const testing = std.testing; + try testing.expectEqual(0, countInvisible("hello", .html)); + try testing.expectEqual(25, countInvisible("hello", .html)); + try testing.expectEqual(1, countInvisible("°C", .html)); // UTF-8 still counted +} + +test "countInvisible - ansi full string" { + const str = "\x1b[38;5;250m .--. \x1b[0m \x1b[38;5;154m+61(+61)\x1b[0m °F "; + try std.testing.expectEqual(28, str.len - countInvisible(str, .ansi)); +} + +test "countInvisible - ansi formatted" { + const str = "\x1b[38;5;154m+61(+61)\x1b[0m °F"; + try std.testing.expectEqual(11, str.len - countInvisible(str, .ansi)); +} diff --git a/src/tests/metno-phoenix-tmp.ansi b/src/tests/metno-phoenix-tmp.ansi new file mode 100644 index 0000000..5d612e7 --- /dev/null +++ b/src/tests/metno-phoenix-tmp.ansi @@ -0,0 +1,38 @@ +Weather report: 33.4484,-112.0741 + +  Cloudy + .--.  +61(+61) °F + .-( ).  ↘ 2 mph + (___.__)__) +  0.0 in + + ┌─────────────┐ +┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐ +│ Morning │ Noon └──────┬──────┘ Evening │ Night │ +├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ +│   Cloudy │  \ /  Partly cloudy │  \ /  Partly cloudy │  \ /  Clear │ +│  .--.  +61(+61) °F │  _ /"".-.  +56(+56) °F │  _ /"".-.  +50(+50) °F │  .-.  +65(+65) °F │ +│  .-( ).  ↘ 2 mph │  \_( ).  ↖ 1 mph │  \_( ).  ↖ 3 mph │  ― ( ) ―  ↖ 3 mph │ +│  (___.__)__)  │  /(___(__)  │  /(___(__)  │  `-'  │ +│   0.0 in | 0% │   0.0 in | 0% │   0.0 in | 0% │  / \  0.0 in | 0% │ +└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ + ┌─────────────┐ +┌──────────────────────────────┬───────────────────────┤ Mon 5 Jan ├───────────────────────┬──────────────────────────────┐ +│ Morning │ Noon └──────┬──────┘ Evening │ Night │ +├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ +│  \ /  Fair │  \ /  Partly cloudy │  \ /  Partly cloudy │  \ /  Partly cloudy │ +│  _ /"".-.  +68(+68) °F │  _ /"".-.  +57(+57) °F │  _ /"".-.  +53(+53) °F │  _ /"".-.  +61(+61) °F │ +│  \_( ).  ↙ 2 mph │  \_( ).  ↖ 3 mph │  \_( ).  ↘ 2 mph │  \_( ).  ← 3 mph │ +│  /(___(__)  │  /(___(__)  │  /(___(__)  │  /(___(__)  │ +│   0.0 in | 0% │   0.0 in | 0% │   0.0 in | 0% │   0.0 in | 0% │ +└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ + ┌─────────────┐ +┌──────────────────────────────┬───────────────────────┤ Tue 6 Jan ├───────────────────────┬──────────────────────────────┐ +│ Morning │ Noon └──────┬──────┘ Evening │ Night │ +├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤ +│   Cloudy │  \ /  Partly cloudy │  \ /  Partly cloudy │  \ /  Partly cloudy │ +│  .--.  +66(+66) °F │  _ /"".-.  +58(+58) °F │  _ /"".-.  +53(+53) °F │  _ /"".-.  +49(+49) °F │ +│  .-( ).  ↘ 2 mph │  \_( ).  ← 3 mph │  \_( ).  ← 2 mph │  \_( ).  ← 2 mph │ +│  (___.__)__)  │  /(___(__)  │  /(___(__)  │  /(___(__)  │ +│   0.0 in | 0% │   0.0 in | 0% │   0.0 in | 0% │   0.0 in | 0% │ +└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘ diff --git a/src/weather/types.zig b/src/weather/types.zig index 6d60307..c61631a 100644 --- a/src/weather/types.zig +++ b/src/weather/types.zig @@ -161,6 +161,12 @@ pub const HourlyForecast = struct { precip_mm: f32, visibility_km: ?f32, + pub fn tempFahrenheit(self: HourlyForecast) f32 { + return celsiusToFahrenheit(self.temp_c); + } + pub fn feelsLikeFahrenheit(self: HourlyForecast) f32 { + return celsiusToFahrenheit(self.feels_like_c); + } pub fn windMph(self: HourlyForecast) f32 { return self.wind_kph * miles_per_km; }