Something went wrong. Try again.
A rainbow login banner drawn in a real font and shown with the kitty graphics protocol, written in Zig.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! zigurine: a short piece of text — the machine's name unless told//! otherwise — drawn in a real font, colored with a rainbow, and shown as an//! image in a terminal that speaks the kitty graphics protocol.//!//! It is meant to run at login, so a terminal that cannot show the banner//! is shown nothing at all rather than escape codes or a fallback.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;const build_options = @import("build_options");const font = @import("font");const fc = @import("font_config");const renderer = @import("font_renderer");const loader = @import("font_loader");const z2d = @import("z2d");const z2dimg = @import("z2dimg");
const banner = @import("banner.zig");const kitty = @import("kitty.zig");const rainbow = @import("rainbow.zig");const terminal = @import("terminal.zig");
const usage = \\usage: zigurine [options] [TEXT...] \\ \\Shows TEXT, or the short hostname when there is none, as a rainbow \\banner in a terminal that supports the kitty graphics protocol. Any \\other terminal is shown nothing. \\ \\ -f, --font PATTERN the font, as a fontconfig pattern (default: Inter:black) \\ --font-file PATH draw with the font in PATH, without fontconfig \\ --font-index N which font of a collection --font-file names (default: 0) \\ --random pick at random among the fonts matching the pattern \\ --rows N the banner's height in terminal rows (default: 4) \\ --angle DEGREES the rainbow's direction, counterclockwise from \\ left to right (default: 45) \\ --cycles N times the rainbow goes round the spectrum (default: 1) \\ --hue DEGREES the hue the rainbow starts at (default: random) \\ -o, --output FILE write the banner to FILE as a PNG instead \\ --height-px N the banner's height in pixels with --output (default: 128) \\ -h, --help show this help \\ --version show the version \\;
/// How long the terminal has to answer, in milliseconds. A terminal on the/// far side of a slow link answers late, but a login that stalls is worse/// than one without a banner.const probe_timeout_ms = 150;
const Options = struct { pattern: []const u8 = "Inter:black", font_file: ?[]const u8 = null, font_index: u32 = 0, random: bool = false, rows: u32 = 4, angle: f32 = 45, cycles: f32 = 1, hue: ?f32 = null, output: ?[]const u8 = null, height_px: u32 = 128, /// The banner text, the arguments joined by spaces; null for the /// hostname. text: ?[]const u8 = null,};
pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io; const arena = init.arena.allocator();
const options = try parseArgs(arena, io, init.minimal.args);
// Where the banner goes and how big it may be. Without --output, a // terminal that cannot show it ends the run here, before any font is // loaded. var height: u32 = options.height_px; var max_width: ?u32 = null; if (options.output == null) { const replies = try terminal.probe(io, probe_timeout_ms) orelse return; if (!replies.graphics) return; // A terminal that shows images but will not say how big a cell is // gets the size of a common one. const cell = replies.cellHeight() orelse 20; height = options.rows * cell; // Leave the last column free, so the cursor has somewhere to be. if (replies.widthPx()) |w| max_width = w -| (replies.cell_width orelse 0); }
var hostname_buf: [std.posix.HOST_NAME_MAX]u8 = undefined; const banner_text = options.text orelse try shortHostname(&hostname_buf);
var surface = try drawBanner(gpa, io, init.environ_map, options, banner_text, height, max_width); defer surface.deinit(gpa);
var hue_start: f32 = undefined; if (options.hue) |h| hue_start = h else { var bytes: [2]u8 = undefined; io.random(&bytes); hue_start = @floatFromInt(std.mem.readInt(u16, &bytes, .little) % 360); } rainbow.paint(&surface, .{ .angle = options.angle, .cycles = options.cycles, .start = hue_start });
var png: Io.Writer.Allocating = .init(gpa); defer png.deinit(); try z2dimg.encode(gpa, &surface, &png.writer, .{ .png = .{} });
if (options.output) |path| { try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = png.written() }); return; } var out_buf: [4096]u8 = undefined; var out = Io.File.stdout().writer(io, &out_buf); try kitty.writeImage(&out.interface, png.written()); try out.interface.flush();}
/// The banner text drawn in white, from the font the options ask for.fn drawBanner( gpa: Allocator, io: Io, environ_map: *const std.process.Environ.Map, options: Options, banner_text: []const u8, height: u32, max_width: ?u32,) !z2d.Surface { if (options.font_file) |path| { const bytes = Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(loader.Loader.max_file_size)) catch |err| std.process.fatal("cannot read {s}: {t}", .{ path, err }); defer gpa.free(bytes); var f = font.Font.parseIndex(gpa, bytes, options.font_index, .{}) catch |err| std.process.fatal("cannot parse {s}: {t}", .{ path, err }); defer f.deinit(); var face = try renderer.Face.init(gpa, &f, .{}); defer face.deinit(gpa); return render(gpa, .{ .face = &face }, banner_text, height, max_width); }
const config = try fc.Config.load(gpa, io, .fromMap(environ_map), .{}); defer config.deinit(); try config.scan(io);
var font_loader: loader.Loader = .init(gpa, io, config); defer font_loader.deinit();
var list = if (options.random) try resolveRandom(gpa, io, config, &font_loader, options.pattern) else font_loader.resolveName(options.pattern) catch |err| std.process.fatal("cannot use font pattern \"{s}\": {t}", .{ options.pattern, err }); defer list.deinit();
return render(gpa, .{ .list = &list }, banner_text, height, max_width);}
fn render(gpa: Allocator, fonts: banner.Fonts, banner_text: []const u8, height: u32, max_width: ?u32) !z2d.Surface { return banner.render(gpa, fonts, banner_text, height, max_width) catch |err| switch (err) { error.NothingToDraw => std.process.fatal("no font has anything to draw for \"{s}\"", .{banner_text}), else => |e| return e, };}
/// A fallback list headed by one font chosen at random from those the/// pattern describes, as `fc-list` lists them: the pattern is matched as it/// is, so a generic family such as `sans-serif`, which no font is called,/// lists nothing.fn resolveRandom(gpa: Allocator, io: Io, config: *const fc.Config, font_loader: *loader.Loader, pattern: []const u8) !loader.FontList { var query = fc.name.parse(gpa, pattern) catch |err| std.process.fatal("cannot parse font pattern \"{s}\": {t}", .{ pattern, err }); defer query.deinit(); const fonts = try config.list(gpa, &query, &.{ .{ .builtin = .family }, .{ .builtin = .style } }); defer { for (fonts) |p| { p.deinit(); gpa.destroy(p); } gpa.free(fonts); } if (fonts.len == 0) std.process.fatal("no installed font matches \"{s}\"", .{pattern});
var bytes: [4]u8 = undefined; io.random(&bytes); const chosen = fonts[std.mem.readInt(u32, &bytes, .little) % fonts.len];
var pick: fc.Pattern = .init(gpa); defer pick.deinit(); if (chosen.getString(.family, 0)) |family| try pick.addString(.family, family); if (chosen.getString(.style, 0)) |style| try pick.addString(.style, style); return font_loader.resolve(&pick);}
/// The machine's name up to its first dot.fn shortHostname(buf: *[std.posix.HOST_NAME_MAX]u8) ![]const u8 { const name = try std.posix.gethostname(buf); return name[0 .. std.mem.findScalar(u8, name, '.') orelse name.len];}
fn parseArgs(arena: Allocator, io: Io, args: std.process.Args) !Options { var options: Options = .{}; var words: std.ArrayList([]const u8) = .empty;
var it = try std.process.Args.Iterator.initAllocator(args, arena); _ = it.skip(); var only_text = false; while (it.next()) |arg| { if (only_text or arg.len == 0 or arg[0] != '-' or std.mem.eql(u8, arg, "-")) { try words.append(arena, try arena.dupe(u8, arg)); continue; } if (std.mem.eql(u8, arg, "--")) { only_text = true; } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { try printAndExit(io, usage); } else if (std.mem.eql(u8, arg, "--version")) { try printAndExit(io, "zigurine " ++ build_options.version ++ "\n"); } else if (std.mem.eql(u8, arg, "--random")) { options.random = true; } else if (flag(arg, "-f", "--font")) { options.pattern = try arena.dupe(u8, value(&it, arg)); } else if (flag(arg, null, "--font-file")) { options.font_file = try arena.dupe(u8, value(&it, arg)); } else if (flag(arg, null, "--font-index")) { options.font_index = number(u32, arg, value(&it, arg)); } else if (flag(arg, null, "--rows")) { options.rows = number(u32, arg, value(&it, arg)); if (options.rows == 0) std.process.fatal("--rows must be at least 1", .{}); } else if (flag(arg, null, "--angle")) { options.angle = number(f32, arg, value(&it, arg)); } else if (flag(arg, null, "--cycles")) { options.cycles = number(f32, arg, value(&it, arg)); } else if (flag(arg, null, "--hue")) { options.hue = number(f32, arg, value(&it, arg)); } else if (flag(arg, "-o", "--output")) { options.output = try arena.dupe(u8, value(&it, arg)); } else if (flag(arg, null, "--height-px")) { options.height_px = number(u32, arg, value(&it, arg)); if (options.height_px == 0) std.process.fatal("--height-px must be at least 1", .{}); } else { std.process.fatal("unknown option {s}\n\n{s}", .{ arg, usage }); } } if (words.items.len > 0) options.text = try std.mem.join(arena, " ", words.items); return options;}
/// Whether `arg` is the option `short` or `long`, either alone or as/// `--long=value`.fn flag(arg: []const u8, short: ?[]const u8, long: []const u8) bool { if (short) |s| if (std.mem.eql(u8, arg, s)) return true; if (!std.mem.startsWith(u8, arg, long)) return false; return arg.len == long.len or arg[long.len] == '=';}
/// The option's value: after its `=`, or else the next argument.fn value(it: *std.process.Args.Iterator, arg: []const u8) []const u8 { if (std.mem.findScalar(u8, arg, '=')) |eq| return arg[eq + 1 ..]; return it.next() orelse std.process.fatal("{s} needs a value", .{arg});}
fn number(comptime T: type, arg: []const u8, s: []const u8) T { const parsed = switch (@typeInfo(T)) { .float => std.fmt.parseFloat(T, s), else => std.fmt.parseInt(T, s, 10), }; return parsed catch std.process.fatal("{s}: \"{s}\" is not a number", .{ arg, s });}
fn printAndExit(io: Io, message: []const u8) !noreturn { var buf: [256]u8 = undefined; var w = Io.File.stdout().writer(io, &buf); try w.interface.writeAll(message); try w.interface.flush(); std.process.exit(0);}
test { _ = banner; _ = kitty; _ = rainbow; _ = terminal;}
test "flag" { try std.testing.expect(flag("-f", "-f", "--font")); try std.testing.expect(flag("--font", "-f", "--font")); try std.testing.expect(flag("--font=Inter", "-f", "--font")); try std.testing.expect(!flag("--font-file", "-f", "--font")); try std.testing.expect(!flag("--font-index", null, "--font-file")); try std.testing.expect(flag("--font-file=x", null, "--font-file"));}