aligned ansi/initial html implementation

This commit is contained in:
Emil Lerch 2026-01-03 23:48:35 -08:00
parent df4f84f603
commit a314fc39d4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 283 additions and 168 deletions

View file

@ -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("<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

@ -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("<span style=\"color:{s}\">{s}</span> {s}\n", .{ cloud_color, art[0], current.condition });
try w.print("<span style=\"color:{s}\">{s}</span> <span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {s}\n", .{ cloud_color, art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit });
try w.print("<span style=\"color:{s}\">{s}</span> {s} <span style=\"color:{s}\">{d:.0}</span> {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("<span style=\"color:{s}\">{s}</span> {d:.0} {s}\n", .{ cloud_color, art[3], visibility, vis_unit });
} else {
try w.print("<span style=\"color:{s}\">{s}</span>\n", .{ cloud_color, std.mem.trimRight(u8, art[3], " ") });
}
try w.print("<span style=\"color:{s}\">{s}</span> {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("<span style=\"color:#bcbcbc\">{s}</span> ", .{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("<span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {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} <span style=\"color:{s}\">{d:.0}</span> {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("<span class=\"c82\">hello</span>", .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));
}

View file

@ -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% │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘

View file

@ -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;
}