const std = @import("std");

const hsluv = @import("hsluv.zig");

const Color = @This();
const dvui = @import("dvui.zig");

const tvg = @import("svg2tvg");

r: u8 = 0xff,
g: u8 = 0xff,
b: u8 = 0xff,
a: u8 = 0xff,

// Basic web colors
// https://en.wikipedia.org/wiki/Web_colors#Basic_colors
pub const white = Color{ .r = 0xFF, .g = 0xFF, .b = 0xFF };
pub const silver = Color{ .r = 0xC0, .g = 0xC0, .b = 0xC0 };
pub const gray = Color{ .r = 0x80, .g = 0x80, .b = 0x80 };
pub const black = Color{ .r = 0x00, .g = 0x00, .b = 0x00 };
pub const red = Color{ .r = 0xFF, .g = 0x00, .b = 0x00 };
pub const maroon = Color{ .r = 0x80, .g = 0x00, .b = 0x00 };
pub const yellow = Color{ .r = 0xFF, .g = 0xFF, .b = 0x00 };
pub const olive = Color{ .r = 0x80, .g = 0x80, .b = 0x00 };
pub const lime = Color{ .r = 0x00, .g = 0xFF, .b = 0x00 };
pub const green = Color{ .r = 0x00, .g = 0x80, .b = 0x00 };
pub const aqua = Color{ .r = 0x00, .g = 0xFF, .b = 0xFF };
pub const teal = Color{ .r = 0x00, .g = 0x80, .b = 0x80 };
pub const blue = Color{ .r = 0x00, .g = 0x00, .b = 0xFF };
pub const navy = Color{ .r = 0x00, .g = 0x00, .b = 0x80 };
pub const fuchsia = Color{ .r = 0xFF, .g = 0x00, .b = 0xFF };
pub const purple = Color{ .r = 0x80, .g = 0x00, .b = 0x80 };

// Aliases for basic colors that are already defined
// https://en.wikipedia.org/wiki/Web_colors#Extended_colors
pub const cyan = aqua;
pub const magenta = fuchsia;
pub const dark_cyan = teal;
pub const dark_magenta = purple;

pub const transparent = Color{ .r = 0, .g = 0, .b = 0, .a = 0 };

/// Returns brightness of the color as a value between 0 and 1
pub fn brightness(self: @This()) f32 {
    const r: f32 = @as(f32, self.r) / 255.0;
    const g: f32 = @as(f32, self.g) / 255.0;
    const b: f32 = @as(f32, self.b) / 255.0;

    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

pub fn toRGBA(self: @This()) [4]u8 {
    return .{ self.r, self.g, self.b, self.a };
}

/// Lighten color by converting to HSLuv, lightening, and back.
pub fn lighten(self: Color, deltal: f32) Color {
    return HSLuv.fromColor(self).lighten(deltal).color();
}

/// https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB
pub const HSV = struct {
    /// Hue 0-360 (degrees)
    h: f32 = 0.0,

    /// Saturation 0-1 (%)
    s: f32 = 1.0,

    /// Value 0-1 (%)
    v: f32 = 1.0,

    /// Alpha 0-1 (%)
    a: f32 = 1.0,

    pub fn fromColor(color: Color) HSV {
        const r: f32 = @as(f32, color.r) / 255.0;
        const g: f32 = @as(f32, color.g) / 255.0;
        const b: f32 = @as(f32, color.b) / 255.0;
        const a: f32 = @as(f32, color.a) / 255.0;

        const max = @max(r, g, b);
        const min = @min(r, g, b);
        const delta = max - min;

        const h = 60 * (if (delta == 0)
            0
        else if (max == r)
            @mod((g - b) / delta, 6)
        else if (max == g)
            (b - r) / delta + 2
        else if (max == b)
            (r - g) / delta + 4
        else
            unreachable);

        const s = if (max == 0) 0 else delta / max;

        return .{ .h = h, .s = s, .v = max, .a = a };
    }

    pub fn toColor(self: HSV) Color {
        const c = self.v * self.s;
        const x = c * (1 - @abs(@mod(self.h / 60, 2) - 1));
        const m = self.v - c;

        const step: i8 = @trunc(self.h / 60);

        const r, const g, const b = switch (step) {
            0 => .{ c, x, 0 },
            1 => .{ x, c, 0 },
            2 => .{ 0, c, x },
            3 => .{ 0, x, c },
            4 => .{ x, 0, c },
            5 => .{ c, 0, x },
            else => return .magenta, // hue was < 0 or >= 360
        };

        return .{
            .r = @round((r + m) * 255),
            .g = @round((g + m) * 255),
            .b = @round((b + m) * 255),
            .a = @round(self.a * 255),
        };
    }

    test toColor {
        try std.testing.expectEqualDeep(Color.black, HSV.toColor(.{ .h = 0, .s = 0, .v = 0 }));
        try std.testing.expectEqualDeep(Color.white, HSV.toColor(.{ .h = 0, .s = 0, .v = 1 }));

        // Hue shouldn't matter with 0 saturation
        try std.testing.expectEqualDeep(Color.black, HSV.toColor(.{ .h = 123, .s = 0, .v = 0 }));
        try std.testing.expectEqualDeep(Color.white, HSV.toColor(.{ .h = 123, .s = 0, .v = 1 }));

        try std.testing.expectEqualDeep(Color.red, HSV.toColor(.{ .h = 0 }));
        try std.testing.expectEqualDeep(Color.yellow, HSV.toColor(.{ .h = 60 }));
        try std.testing.expectEqualDeep(Color.lime, HSV.toColor(.{ .h = 120 }));
        try std.testing.expectEqualDeep(Color.cyan, HSV.toColor(.{ .h = 180 }));
        try std.testing.expectEqualDeep(Color.blue, HSV.toColor(.{ .h = 240 }));
        try std.testing.expectEqualDeep(Color.magenta, HSV.toColor(.{ .h = 300 }));

        // our silver color is 0xC0, and v == 0.75 is 0xBF
        try std.testing.expectEqualDeep(Color{ .r = 0xBF, .g = 0xBF, .b = 0xBF }, HSV.toColor(.{ .h = 0, .s = 0, .v = 0.75 }));
        try std.testing.expectEqualDeep(Color.gray, HSV.toColor(.{ .h = 0, .s = 0, .v = 0.5 }));

        try std.testing.expectEqualDeep(Color.maroon, HSV.toColor(.{ .h = 0, .v = 0.5 }));
        try std.testing.expectEqualDeep(Color.olive, HSV.toColor(.{ .h = 60, .v = 0.5 }));
        try std.testing.expectEqualDeep(Color.green, HSV.toColor(.{ .h = 120, .v = 0.5 }));
        try std.testing.expectEqualDeep(Color.teal, HSV.toColor(.{ .h = 180, .v = 0.5 }));
        try std.testing.expectEqualDeep(Color.purple, HSV.toColor(.{ .h = 300, .v = 0.5 }));
    }

    test fromColor {
        try std.testing.expectEqualDeep(Color.black, HSV.fromColor(.black).toColor());
        try std.testing.expectEqualDeep(Color.white, HSV.fromColor(.white).toColor());

        try std.testing.expectEqualDeep(Color.red, HSV.fromColor(.red).toColor());
        try std.testing.expectEqualDeep(Color.yellow, HSV.fromColor(.yellow).toColor());
        try std.testing.expectEqualDeep(Color.lime, HSV.fromColor(.lime).toColor());
        try std.testing.expectEqualDeep(Color.cyan, HSV.fromColor(.cyan).toColor());
        try std.testing.expectEqualDeep(Color.blue, HSV.fromColor(.blue).toColor());
        try std.testing.expectEqualDeep(Color.magenta, HSV.fromColor(.magenta).toColor());

        // our silver color is 0xC0, and v == 0.75 is 0xBF
        try std.testing.expectEqualDeep(Color{ .r = 0xBF, .g = 0xBF, .b = 0xBF }, HSV.fromColor(.{ .r = 0xBF, .g = 0xBF, .b = 0xBF }).toColor());
        try std.testing.expectEqualDeep(Color.gray, HSV.fromColor(.gray).toColor());

        try std.testing.expectEqualDeep(Color.maroon, HSV.fromColor(.maroon).toColor());
        try std.testing.expectEqualDeep(Color.olive, HSV.fromColor(.olive).toColor());
        try std.testing.expectEqualDeep(Color.green, HSV.fromColor(.green).toColor());
        try std.testing.expectEqualDeep(Color.teal, HSV.fromColor(.teal).toColor());
        try std.testing.expectEqualDeep(Color.purple, HSV.fromColor(.purple).toColor());
    }
};

/// Hue Saturation Lightness
///
/// https://www.hsluv.org/
/// src/hsluv.zig is hand-translated from https://github.com/hsluv/hsluv-c
pub const HSLuv = struct {
    /// Hue 0-360
    h: f32 = 0.0,

    /// Saturation 0-100
    s: f32 = 100.0,

    /// Lightness 0-100
    l: f32 = 100.0,

    /// Alpha 0-100
    a: f32 = 100.0,

    pub fn color(self: HSLuv) Color {
        return Color.fromHSLuv(self.h, self.s, self.l, self.a);
    }

    pub fn fromColor(c: Color) HSLuv {
        var ret: HSLuv = undefined;
        hsluv.rgb2hsluv(
            @as(f32, c.r) / 255.0,
            @as(f32, c.g) / 255.0,
            @as(f32, c.b) / 255.0,
            &ret.h,
            &ret.s,
            &ret.l,
        );
        ret.a = @as(f32, c.a) * 100.0 / 255.0;
        return ret;
    }

    pub fn lighten(self: HSLuv, deltal: f32) HSLuv {
        return .{
            .h = self.h,
            .s = self.s,
            .l = std.math.clamp(self.l + deltal, 0, 100),
            .a = self.a,
        };
    }
};

pub fn fromHSLuv(h: f32, s: f32, l: f32, a: f32) Color {
    var r: f32 = undefined;
    var g: f32 = undefined;
    var b: f32 = undefined;
    hsluv.hsluv2rgb(h, s, l, &r, &g, &b);
    return Color{
        .r = @trunc(r * 255.99),
        .g = @trunc(g * 255.99),
        .b = @trunc(b * 255.99),
        .a = @trunc(a / 100.0 * 255.99),
    };
}

/// Multiply the current opacity with `mult`, usually between 0 and 1
pub fn opacity(self: Color, mult: f32) Color {
    if (mult > 1) return self;
    return Color{
        .r = self.r,
        .g = self.g,
        .b = self.b,
        .a = @trunc(std.math.clamp(@as(f32, self.a) * mult, 0, 255)),
    };
}

pub fn format(self: *const Color, writer: *std.Io.Writer) !void {
    try writer.print("Color{{ {x} {x} {x} {x} }}", .{ self.r, self.g, self.b, self.a });
}

/// Linear interpolocation of colors component wise
pub fn lerp(self: Color, other: Color, t: f32) Color {
    if (t <= 0) return self;
    if (t >= 1) return other;
    const r: f32 = std.math.lerp(@as(f32, self.r) / 255, @as(f32, other.r) / 255, t);
    const g: f32 = std.math.lerp(@as(f32, self.g) / 255, @as(f32, other.g) / 255, t);
    const b: f32 = std.math.lerp(@as(f32, self.b) / 255, @as(f32, other.b) / 255, t);
    const a: f32 = std.math.lerp(@as(f32, self.a) / 255, @as(f32, other.a) / 255, t);
    return Color{
        .r = @trunc(r * 255.99),
        .g = @trunc(g * 255.99),
        .b = @trunc(b * 255.99),
        .a = @trunc(a * 255.99),
    };
}

/// Average two colors component-wise
pub fn average(self: Color, other: Color) Color {
    return Color{
        .r = @intCast((@as(u9, @intCast(self.r)) + other.r) / 2),
        .g = @intCast((@as(u9, @intCast(self.g)) + other.g) / 2),
        .b = @intCast((@as(u9, @intCast(self.b)) + other.b) / 2),
        .a = @intCast((@as(u9, @intCast(self.a)) + other.a) / 2),
    };
}

/// A color premultiplied by alpha
///
/// This type is safe to `@bitCast` to and from a `[@sizeOf(Color.PMA)]u8`.
/// This also means `@ptrCast` of `[]Color.PMA` to and from `[]u8` is safe,
/// as long as the length is a multiple of `@sizeOf(Color.PMA)`.
///
/// The pixel format will always be .rgba_32, but the byte order depends on the
/// endianness of the target.
pub const PMA = extern struct {
    r: u8 = 0xff,
    g: u8 = 0xff,
    b: u8 = 0xff,
    a: u8 = 0xff,

    // duplicated names from Color
    pub const white: PMA = .fromColor(.white);
    pub const silver: PMA = .fromColor(.silver);
    pub const gray: PMA = .fromColor(.gray);
    pub const black: PMA = .fromColor(.black);
    pub const red: PMA = .fromColor(.red);
    pub const maroon: PMA = .fromColor(.maroon);
    pub const yellow: PMA = .fromColor(.yellow);
    pub const olive: PMA = .fromColor(.olive);
    pub const lime: PMA = .fromColor(.lime);
    pub const green: PMA = .fromColor(.green);
    pub const aqua: PMA = .fromColor(.aqua);
    pub const teal: PMA = .fromColor(.teal);
    pub const blue: PMA = .fromColor(.blue);
    pub const navy: PMA = .fromColor(.navy);
    pub const fuchsia: PMA = .fromColor(.fuchsia);
    pub const purple: PMA = .fromColor(.purple);
    pub const cyan = PMA.aqua;
    pub const magenta = PMA.fuchsia;
    pub const darl_cyan = PMA.teal;
    pub const dark_magenta = PMA.purple;

    pub const transparent = PMA{ .r = 0, .g = 0, .b = 0, .a = 0 };

    comptime {
        std.debug.assert(@sizeOf(Color.PMA) == 4);
        // Validate that the memory layout of @ptrCast(Color.PMA) is the same as an RGBA []u8
        std.debug.assert(std.mem.eql(u8, &.{ 12, 34, 56, 78 }, @ptrCast(&[_]Color.PMA{.{ .r = 12, .g = 34, .b = 56, .a = 78 }})));
    }

    /// Convert premultiplied alpha color to a `Color`.
    pub fn toColor(self: PMA) Color {
        if (self.a == 0xFF or self.a == 0) return self.castToColor();
        return .{
            .r = @intCast(@divTrunc(@as(u16, self.r) * 255, self.a)),
            .g = @intCast(@divTrunc(@as(u16, self.g) * 255, self.a)),
            .b = @intCast(@divTrunc(@as(u16, self.b) * 255, self.a)),
            .a = self.a,
        };
    }

    /// Convert normal color to premultiplied alpha.
    pub fn fromColor(color: Color) PMA {
        if (color.a == 0xFF) return .cast(color);
        if (color.a == 0) return .transparent;
        return .{
            .r = @intCast(@divTrunc(@as(u16, color.r) * color.a, 255)),
            .g = @intCast(@divTrunc(@as(u16, color.g) * color.a, 255)),
            .b = @intCast(@divTrunc(@as(u16, color.b) * color.a, 255)),
            .a = color.a,
        };
    }

    /// Multiply two colors component-wise.
    pub fn multiply(self: PMA, other: PMA) PMA {
        return .{
            .r = @intCast(@divTrunc(@as(u16, self.r) * other.r, 255)),
            .g = @intCast(@divTrunc(@as(u16, self.g) * other.g, 255)),
            .b = @intCast(@divTrunc(@as(u16, self.b) * other.b, 255)),
            .a = @intCast(@divTrunc(@as(u16, self.a) * other.a, 255)),
        };
    }

    /// Casts from `Color` by reassigning its fields
    ///
    /// Should only be used for valid PMA colors like:
    /// - Any `Color` with a == 0xFF (opaque)
    /// - A fully transparent color (`Color.transparent`)
    pub fn cast(color: Color) PMA {
        return .{ .r = color.r, .g = color.g, .b = color.b, .a = color.a };
    }

    /// Casts to `Color` by reassigning its fields
    pub fn castToColor(self: PMA) Color {
        return .{ .r = self.r, .g = self.g, .b = self.b, .a = self.a };
    }

    /// Alpha multiplies `pixels` in place
    pub fn sliceFromRGBA(pixels: []u8) []Color.PMA {
        for (0..pixels.len / 4) |ii| {
            const i = ii * 4;
            const a = pixels[i + 3];
            pixels[i + 0] = @intCast(@divTrunc(@as(u16, pixels[i + 0]) * a, 255));
            pixels[i + 1] = @intCast(@divTrunc(@as(u16, pixels[i + 1]) * a, 255));
            pixels[i + 2] = @intCast(@divTrunc(@as(u16, pixels[i + 2]) * a, 255));
        }
        return @ptrCast(pixels);
    }

    /// Unapplies the alpha multiplication in place, returning the inner slice
    pub fn sliceToRGBA(pma_pixels: []Color.PMA) []u8 {
        var pixels: []u8 = @ptrCast(pma_pixels);
        for (0..pixels.len / 4) |ii| {
            const i = ii * 4;
            const a = pixels[i + 3];
            if (a == 0) continue;
            pixels[i + 0] = @intCast(@divTrunc(@as(u16, pixels[i + 0]) * 255, a));
            pixels[i + 1] = @intCast(@divTrunc(@as(u16, pixels[i + 1]) * 255, a));
            pixels[i + 2] = @intCast(@divTrunc(@as(u16, pixels[i + 2]) * 255, a));
        }
        return pixels;
    }
};

pub const PMAImage = struct {
    pma: []PMA,
    width: u32,
    height: u32,

    /// the returned []PMA inside PMAImage is allocated with alloc
    pub fn fromImageFile(dbg_name: []const u8, alloc: std.mem.Allocator, image_bytes: []const u8) !PMAImage {
        var w: c_int = undefined;
        var h: c_int = undefined;
        var channels_in_file: c_int = undefined;
        const data = dvui.c.stbi_load_from_memory(image_bytes.ptr, @as(c_int, @intCast(image_bytes.len)), &w, &h, &channels_in_file, 4);
        if (data == null) {
            dvui.log.warn("imageTexture stbi_load error on image \"{s}\": {s}\n", .{ dbg_name, dvui.c.stbi_failure_reason() });
            return dvui.StbImageError.stbImageError;
        }
        defer dvui.c.stbi_image_free(data);

        const size: usize = @intCast(w * h * 4);
        const pixels = try alloc.alloc(u8, size);
        @memcpy(pixels, data[0..size]);

        return PMAImage{
            .pma = PMA.sliceFromRGBA(pixels),
            .width = @intCast(w),
            .height = @intCast(h),
        };
    }

    /// the returned []PMA inside PMAImage is allocated with alloc
    /// the render_alloc is used for temporary allocations in the render process
    pub fn fromTvgFile(dbg_name: []const u8, alloc: std.mem.Allocator, render_alloc: std.mem.Allocator, tvg_bytes: []const u8, height: u32, icon_opts: dvui.IconRenderOptions) !PMAImage {
        if (comptime !dvui.useTvg) {
            comptime unreachable;
        }
        const ImageAdapter = struct {
            pixels: []u8,
            width: u32,
            height: u32,
            pub fn setPixel(self: *@This(), x: usize, y: usize, col: [4]u8) void {
                const idx = (y * self.width + x) * 4;
                for (0..4) |i| self.pixels[idx + i] = col[i];
            }
            pub fn getPixel(self: *@This(), x: usize, y: usize) [4]u8 {
                const idx = y * self.width + x;
                const slice = self.pixels[idx * 4 .. (idx + 1) * 4];
                var col: [4]u8 = undefined;
                for (&col, slice) |*a, s| a.* = s;
            }
            fn conv(dcol: dvui.Color) tvg.Color {
                return tvg.Color{
                    .r = @as(f32, dcol.r) / 255.0,
                    .g = @as(f32, dcol.g) / 255.0,
                    .b = @as(f32, dcol.b) / 255.0,
                    .a = @as(f32, dcol.a) / 255.0,
                };
            }
        };

        var width: u32 = @trunc(try dvui.iconWidth(dbg_name, tvg_bytes, @floatFromInt(height)));

        // height will be at least 1, but iconWidth could return < 1, truncated
        // to 0, which would fail rendering
        width = @max(width, 1);

        const img_raw_data = try alloc.alloc(u8, width * height * 4);

        @memset(img_raw_data, 0);
        var img = ImageAdapter{
            .pixels = img_raw_data,
            .width = width,
            .height = height,
        };
        var fb: std.Io.Reader = .fixed(tvg_bytes);

        var ow_stroke: ?tvg.Color = null;
        if (icon_opts.stroke_color) |cx| ow_stroke = ImageAdapter.conv(cx);
        var ow_fill: ?tvg.Color = null;
        var disable_fill = false;
        if (ow_fill != null and ow_fill.?.a == 0.0) disable_fill = true;
        if (icon_opts.fill_color) |cx| ow_fill = ImageAdapter.conv(cx);
        tvg.renderStream(dvui.io, render_alloc, &img, &fb, .{
            .overwrite_stroke_width = icon_opts.stroke_width,
            .overwrite_stroke = ow_stroke,
            .overwrite_fill = ow_fill,
            .disable_fill = disable_fill,
        }) catch |err| {
            dvui.log.warn("iconTexture Tinyvg error {any} rendering icon {s} at height {d}\n", .{ err, dbg_name, height });
            return dvui.TvgError.tvgError;
        };
        return PMAImage{
            .pma = std.mem.bytesAsSlice(PMA, img.pixels),
            .width = img.width,
            .height = img.height,
        };
    }
};

pub const HexString = [7]u8;

/// Returns a hex color string in the format "#rrggbb"
pub fn toHexString(self: Color) HexString {
    var result: HexString = undefined;
    _ = std.fmt.bufPrint(&result, "#{x:0>2}{x:0>2}{x:0>2}", .{ self.r, self.g, self.b }) catch unreachable;
    return result;
}

test toHexString {
    try std.testing.expectEqual((Color{ .r = 0x1, .g = 0x2, .b = 0x3, .a = 0xFF }).toHexString(), "#010203".*);
    try std.testing.expectEqual((Color{ .r = 0x1, .g = 0x2, .b = 0x3, .a = 0x4 }).toHexString(), "#010203".*);
    try std.testing.expectEqual((Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xFF }).toHexString(), "#a1a2a3".*);
    try std.testing.expectEqual((Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xa4 }).toHexString(), "#a1a2a3".*);
}

/// Converts hex color string to `Color`
///
/// If `hex_color` is invalid, an error is logged and a default color is returned.
/// In comptime an invalid `hex_color` will cause a compile error.
///
/// See `tryFromHex` for a version that returns an error.
///
/// Supports the following formats:
/// - `#RGB`
/// - `#RGBA`
/// - `#RRGGBB`
/// - `#RRGGBBAA`
/// - `RGB`
/// - `RGBA`
/// - `RRGGBB`
/// - `RRGGBBAA`
pub fn fromHex(hex_color: []const u8) Color {
    return tryFromHex(hex_color) catch |err| if (@inComptime()) {
        @compileError(std.fmt.comptimePrint("Failed to parse hex color string: {any}", .{err}));
    } else {
        std.log.err("Failed to parse hex color string: {any}", .{err});
        return magenta;
    };
}

pub const FromHexError = std.fmt.ParseIntError || error{
    /// The string had a different length that expected for the supported formats
    InvalidHexStringLength,
};

/// Converts hex color string to `Color`
///
/// Supports the following formats:
/// - `#RGB`
/// - `#RGBA`
/// - `#RRGGBB`
/// - `#RRGGBBAA`
/// - `RGB`
/// - `RGBA`
/// - `RRGGBB`
/// - `RRGGBBAA`
pub fn tryFromHex(hex_color: []const u8) FromHexError!Color {
    if (hex_color.len == 0) return error.InvalidHexStringLength;
    const hex = if (hex_color[0] == '#') hex_color[1..] else hex_color;

    const is_nibble_size, const has_alpha = switch (hex.len) {
        3 => .{ true, false },
        4 => .{ true, true },
        6 => .{ false, false },
        8 => .{ false, true },
        else => return error.InvalidHexStringLength,
    };
    const num = std.fmt.parseUnsigned(u32, hex, 16) catch |err| switch (err) {
        std.fmt.ParseIntError.Overflow => unreachable, // Length and base is known, cannot overflow
        std.fmt.ParseIntError.InvalidCharacter => |e| return e,
    };

    const mult: u32 = if (is_nibble_size) 0x10 else 1;
    const mask: u32 = if (is_nibble_size) 0xf else 0xff;
    const step: u5 = if (is_nibble_size) 4 else 8;
    const offset: u5 = @intFromBool(has_alpha);
    return .{
        .r = @intCast(mult * ((num >> step * (2 + offset)) & mask)),
        .g = @intCast(mult * ((num >> step * (1 + offset)) & mask)),
        .b = @intCast(mult * ((num >> step * (0 + offset)) & mask)),
        .a = if (has_alpha) @intCast(mult * (num & mask)) else 0xff,
    };
}

test tryFromHex {
    try std.testing.expectEqual(Color{ .r = 0x10, .g = 0x20, .b = 0x30, .a = 0xff }, Color.tryFromHex("123"));
    try std.testing.expectEqual(Color{ .r = 0x10, .g = 0x20, .b = 0x30, .a = 0xff }, Color.tryFromHex("#123"));
    try std.testing.expectEqual(Color{ .r = 0x10, .g = 0x20, .b = 0x30, .a = 0x40 }, Color.tryFromHex("1234"));
    try std.testing.expectEqual(Color{ .r = 0x10, .g = 0x20, .b = 0x30, .a = 0x40 }, Color.tryFromHex("#1234"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xff }, Color.tryFromHex("a1a2a3"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xff }, Color.tryFromHex("#a1a2a3"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xa4 }, Color.tryFromHex("a1a2a3a4"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xa4 }, Color.tryFromHex("#a1a2a3a4"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xff }, Color.tryFromHex("A1A2A3"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xff }, Color.tryFromHex("#A1A2A3"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xa4 }, Color.tryFromHex("A1A2A3A4"));
    try std.testing.expectEqual(Color{ .r = 0xa1, .g = 0xa2, .b = 0xa3, .a = 0xa4 }, Color.tryFromHex("#A1A2A3A4"));
    try std.testing.expectEqual(FromHexError.InvalidCharacter, Color.tryFromHex("XXX"));
    try std.testing.expectEqual(FromHexError.InvalidHexStringLength, Color.tryFromHex("#12"));
}

test {
    @import("std").testing.refAllDecls(@This());
}
