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
+
+[38;5;250m [0m Cloudy
+[38;5;250m .--. [0m [38;5;154m+61(+61)[0m °F
+[38;5;250m .-( ). [0m ↘ [38;5;242m2[0m mph
+[38;5;250m (___.__)__)[0m
+[38;5;250m [0m 0.0 in
+
+ ┌─────────────┐
+┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐
+│ Morning │ Noon └──────┬──────┘ Evening │ Night │
+├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
+│ [38;5;250m [0m Cloudy │ [38;5;250m \ / [0m Partly cloudy │ [38;5;250m \ / [0m Partly cloudy │ [38;5;250m \ / [0m Clear │
+│ [38;5;250m .--. [0m [38;5;154m+61(+61)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;118m+56(+56)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;82m+50(+50)[0m °F │ [38;5;250m .-. [0m [38;5;190m+65(+65)[0m °F │
+│ [38;5;250m .-( ). [0m ↘ [38;5;242m2[0m mph │ [38;5;250m \_( ). [0m ↖ [38;5;241m1[0m mph │ [38;5;250m \_( ). [0m ↖ [38;5;242m3[0m mph │ [38;5;250m ― ( ) ― [0m ↖ [38;5;242m3[0m mph │
+│ [38;5;250m (___.__)__) [0m │ [38;5;250m /(___(__) [0m │ [38;5;250m /(___(__) [0m │ [38;5;250m `-' [0m │
+│ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m / \ [0m 0.0 in | 0% │
+└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
+ ┌─────────────┐
+┌──────────────────────────────┬───────────────────────┤ Mon 5 Jan ├───────────────────────┬──────────────────────────────┐
+│ Morning │ Noon └──────┬──────┘ Evening │ Night │
+├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
+│ [38;5;250m \ / [0m Fair │ [38;5;250m \ / [0m Partly cloudy │ [38;5;250m \ / [0m Partly cloudy │ [38;5;250m \ / [0m Partly cloudy │
+│ [38;5;250m _ /"".-. [0m [38;5;190m+68(+68)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;118m+57(+57)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;82m+53(+53)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;154m+61(+61)[0m °F │
+│ [38;5;250m \_( ). [0m ↙ [38;5;241m2[0m mph │ [38;5;250m \_( ). [0m ↖ [38;5;242m3[0m mph │ [38;5;250m \_( ). [0m ↘ [38;5;242m2[0m mph │ [38;5;250m \_( ). [0m ← [38;5;242m3[0m mph │
+│ [38;5;250m /(___(__) [0m │ [38;5;250m /(___(__) [0m │ [38;5;250m /(___(__) [0m │ [38;5;250m /(___(__) [0m │
+│ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 0.0 in | 0% │
+└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
+ ┌─────────────┐
+┌──────────────────────────────┬───────────────────────┤ Tue 6 Jan ├───────────────────────┬──────────────────────────────┐
+│ Morning │ Noon └──────┬──────┘ Evening │ Night │
+├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
+│ [38;5;250m [0m Cloudy │ [38;5;250m \ / [0m Partly cloudy │ [38;5;250m \ / [0m Partly cloudy │ [38;5;250m \ / [0m Partly cloudy │
+│ [38;5;250m .--. [0m [38;5;190m+66(+66)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;118m+58(+58)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;82m+53(+53)[0m °F │ [38;5;250m _ /"".-. [0m [38;5;82m+49(+49)[0m °F │
+│ [38;5;250m .-( ). [0m ↘ [38;5;242m2[0m mph │ [38;5;250m \_( ). [0m ← [38;5;242m3[0m mph │ [38;5;250m \_( ). [0m ← [38;5;242m2[0m mph │ [38;5;250m \_( ). [0m ← [38;5;241m2[0m mph │
+│ [38;5;250m (___.__)__) [0m │ [38;5;250m /(___(__) [0m │ [38;5;250m /(___(__) [0m │ [38;5;250m /(___(__) [0m │
+│ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 0.0 in | 0% │ [38;5;250m [0m 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;
}