diff --git a/build.zig b/build.zig index 08b5661..7151001 100644 --- a/build.zig +++ b/build.zig @@ -16,6 +16,22 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + const zigimg = b.dependency("zigimg", .{ + .target = target, + .optimize = optimize, + }); + + const freetype = b.dependency("ghostty", .{ + .target = target, + .optimize = optimize, + }).builder.dependency("freetype", .{ + .target = target, + .optimize = optimize, + }); + + const jetbrains_mono = b.dependency("jetbrains_mono", .{}); + const nerd_fonts = b.dependency("nerd_fonts_symbols_only", .{}); + const openflights = b.dependency("openflights", .{}); const maxminddb_upstream = b.dependency("maxminddb", .{}); @@ -113,9 +129,17 @@ pub fn build(b: *std.Build) void { }); root_module.addImport("httpz", httpz.module("httpz")); root_module.addImport("zeit", zeit.module("zeit")); + root_module.addImport("zigimg", zigimg.module("zigimg")); + root_module.addImport("freetype", freetype.module("freetype")); root_module.addAnonymousImport("airports.dat", .{ .root_source_file = openflights.path("data/airports.dat"), }); + root_module.addAnonymousImport("JetBrainsMono-Regular.ttf", .{ + .root_source_file = jetbrains_mono.path("fonts/ttf/JetBrainsMono-Regular.ttf"), + }); + root_module.addAnonymousImport("SymbolsNerdFont-Regular.ttf", .{ + .root_source_file = nerd_fonts.path("SymbolsNerdFont-Regular.ttf"), + }); root_module.addOptions("build_options", build_options); root_module.addIncludePath(maxminddb_upstream.path("include")); root_module.addIncludePath(b.path("libs/phoon_14Aug2014")); @@ -125,6 +149,7 @@ pub fn build(b: *std.Build) void { maxminddb, phoon, sunriset, + freetype.artifact("freetype"), }; const exe = b.addExecutable(.{ .name = "wttr", diff --git a/build.zig.zon b/build.zig.zon index 5ed0912..1b22391 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -20,6 +20,22 @@ }, .phoon = .{ .path = "libs/phoon_14Aug2014" }, .sunriset = .{ .path = "libs/sunriset" }, + .ghostty = .{ + .url = "git+https://github.com/ghostty-org/ghostty#ec2912dbafe50cc32b786d2327dcd0213c83ecc6", + .hash = "ghostty-1.3.0-dev-5UdBC_y2RASwYWn5fjn71WsP-arlg8wSICLc0rYiozdf", + }, + .zigimg = .{ + .url = "git+https://github.com/zigimg/zigimg#9714df09f76891323c7fdbbbf23a17b79024fffb", + .hash = "zigimg-0.1.0-8_eo2j4mFwCU7tWnqvkYtzqe-OPRn_bxEql_IJhW85LT", + }, + .jetbrains_mono = .{ + .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", + .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", + }, + .nerd_fonts_symbols_only = .{ + .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", + .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", + }, }, .fingerprint = 0x710c2b57e81aa678, .minimum_zig_version = "0.15.2", diff --git a/src/http/QueryParams.zig b/src/http/QueryParams.zig index a463260..bf9c02e 100644 --- a/src/http/QueryParams.zig +++ b/src/http/QueryParams.zig @@ -26,6 +26,8 @@ format: ?[]const u8 = null, lang: ?[]const u8 = null, location: ?[]const u8 = null, transparency: ?u8 = null, +background: ?[]const u8 = null, +add_frame: bool = false, /// A: Ignore user agent and force ansi mode ansi: bool = false, /// T: Avoid terminal sequences and just output plain text @@ -60,6 +62,7 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam 'A' => params.ansi = true, 'T' => params.text_only = true, 't' => params.transparency = 150, + 'p' => params.add_frame = true, else => continue, } } @@ -77,6 +80,8 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam if (value) |v| { params.transparency = try std.fmt.parseInt(u8, v, 10); } + } else if (std.mem.eql(u8, key, "background")) { + params.background = if (value) |v| try allocator.dupe(u8, v) else null; } } diff --git a/src/http/handler.zig b/src/http/handler.zig index 1507829..1f6202f 100644 --- a/src/http/handler.zig +++ b/src/http/handler.zig @@ -9,7 +9,9 @@ const Json = @import("../render/Json.zig"); const V2 = @import("../render/V2.zig"); const Custom = @import("../render/Custom.zig"); const Prometheus = @import("../render/Prometheus.zig"); +const Png = @import("../render/Png.zig"); const help = @import("help.zig"); +const types = @import("../weather/types.zig"); const log = std.log.scoped(.handler); @@ -33,12 +35,14 @@ pub fn handleWeather( defer { if (params.format) |f| req.arena.free(f); if (params.lang) |l| req.arena.free(l); + if (params.background) |b| req.arena.free(b); } if (params.location) |loc| { break :blk loc; } else break :blk client_ip; // no location, just use client ip instead }; + if (std.mem.eql(u8, "favicon.ico", location)) { res.header("Content-Type", "image/x-icon"); res.body = @embedFile("favicon.ico"); @@ -75,16 +79,20 @@ fn handleWeatherInternal( ) !void { const req_alloc = req.arena; + // Check for PNG request + const is_png = std.mem.endsWith(u8, location_query, ".png"); + const location_str = if (is_png) location_query[0 .. location_query.len - 4] else location_query; + // Resolve location. By the time we get here, we really // should have a location from the path, query string, or // client IP lookup. So if we have an empty location parameter, it // is better to 404 than to fake it with a London response - if (location_query.len == 0) { + if (location_str.len == 0) { res.status = 404; res.body = "Location not found\n"; return; } - const location = opts.resolver.resolve(location_query) catch |err| { + const location = opts.resolver.resolve(location_str) catch |err| { switch (err) { error.LocationNotFound => { log.debug("Location not found for query {s}", .{location_query}); @@ -118,6 +126,7 @@ fn handleWeatherInternal( defer { if (params.format) |f| req_alloc.free(f); if (params.lang) |l| req_alloc.free(l); + if (params.background) |b| req_alloc.free(b); } var render_options = params.render_options; @@ -140,50 +149,70 @@ fn handleWeatherInternal( const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude }); res.headers.add("X-Location-Coordinates", coords_header); + // Render weather data + if (is_png) { + res.content_type = .PNG; + var png_renderer = Png.init(req_alloc); + defer png_renderer.deinit(); + + var png_buffer: [1024 * 1024]u8 = undefined; + var png_writer_impl = std.Io.Writer.fixed(&png_buffer); + const png_writer = &png_writer_impl; + + render_options.format = .ansi; // Force ANSI for PNG + try renderWeatherData(png_writer, weather, params, render_options); + + const text_output = png_buffer[0..png_writer_impl.end]; + try png_renderer.buffer.appendSlice(req_alloc, text_output); + + const png_options = Png.PngOptions{ + .transparency = params.transparency orelse 150, + .background = params.background, + .add_frame = params.add_frame, + }; + try png_renderer.render(res.writer(), png_options); + return; + } + // Set content type based on format if (params.format) |fmt| { - // Anything except the json will be plain text - res.content_type = .TEXT; - if (std.mem.eql(u8, fmt, "1")) { - try Line.render(res.writer(), weather, .@"1", render_options.use_imperial); - return; - } - if (std.mem.eql(u8, fmt, "2")) { - try Line.render(res.writer(), weather, .@"2", render_options.use_imperial); - return; - } - if (std.mem.eql(u8, fmt, "3")) { - try Line.render(res.writer(), weather, .@"3", render_options.use_imperial); - return; - } - if (std.mem.eql(u8, fmt, "4")) { - try Line.render(res.writer(), weather, .@"4", render_options.use_imperial); - return; - } - if (std.mem.eql(u8, fmt, "j1")) { - res.content_type = .JSON; // reset to json - try Json.render(res.writer(), weather); - return; - } - if (std.mem.eql(u8, fmt, "p1")) { - try Prometheus.render(res.writer(), weather); - return; - } - if (std.mem.eql(u8, fmt, "v2")) { - try V2.render(res.writer(), weather, render_options.use_imperial); - return; - } - // Everything else goes to Custom renderer - try Custom.render(res.writer(), weather, fmt, render_options.use_imperial); + res.content_type = if (std.mem.eql(u8, fmt, "j1")) .JSON else .TEXT; } else { - // No specific format selected, we'll provide Formatted output in either - // text (ansi/plain) or html render_options.format = determineFormat(params, req.headers.get("user-agent")); log.debug( "Format: {}. params.ansi {}, params.text {}, user agent: {?s}", .{ render_options.format, params.ansi, params.text_only, req.headers.get("user-agent") }, ); - if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML; - try Formatted.render(res.writer(), weather, render_options); + res.content_type = if (render_options.format == .html) .HTML else .TEXT; + } + try renderWeatherData(res.writer(), weather, params, render_options); +} + +fn renderWeatherData( + writer: *std.Io.Writer, + weather: types.WeatherData, + params: QueryParams, + render_options: Formatted.RenderOptions, +) !void { + if (params.format) |fmt| { + if (std.mem.eql(u8, fmt, "1")) { + try Line.render(writer, weather, .@"1", render_options.use_imperial); + } else if (std.mem.eql(u8, fmt, "2")) { + try Line.render(writer, weather, .@"2", render_options.use_imperial); + } else if (std.mem.eql(u8, fmt, "3")) { + try Line.render(writer, weather, .@"3", render_options.use_imperial); + } else if (std.mem.eql(u8, fmt, "4")) { + try Line.render(writer, weather, .@"4", render_options.use_imperial); + } else if (std.mem.eql(u8, fmt, "j1")) { + try Json.render(writer, weather); + } else if (std.mem.eql(u8, fmt, "p1")) { + try Prometheus.render(writer, weather); + } else if (std.mem.eql(u8, fmt, "v2")) { + try V2.render(writer, weather, render_options.use_imperial); + } else { + try Custom.render(writer, weather, fmt, render_options.use_imperial); + } + } else { + try Formatted.render(writer, weather, render_options); } }