improve light theme detection

This commit is contained in:
Jake Stewart
2026-02-20 07:55:47 +08:00
committed by Mitchell Hashimoto
parent c4c87f8c85
commit f66a84b18a
3 changed files with 141 additions and 31 deletions

View File

@@ -808,9 +808,21 @@ palette: Palette = .{},
/// Available since: 1.3.0
@"palette-generate": bool = true,
/// Whether to invert generated light themes colors (see `palette-generate`).
/// This helps give the 256-color palette more semantic meaning.
///
/// Invert the palette colors generated when `palette-generate` is enabled,
/// so that the colors go in reverse order. This allows palette-based
/// applications to work well in both light and dark mode since the
/// palettes are always relatively good colors.
///
/// This defaults to off because some legacy terminal applications
/// hardcode the assumption that palette indices 16231 are ordered from
/// darkest to lightest, so enabling this would make them unreadable.
/// This is not a generally good assumption and we encourage modern
/// terminal applications to use the indices in a more semantic way.
///
/// This has no effect if `palette-generate` is disabled.
///
/// For more information see `palette-generate`.
///
/// Available since: 1.3.0
@"palette-harmonious": bool = false,

View File

@@ -1,10 +1,9 @@
const std = @import("std");
const colorpkg = @This();
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const x11_color = @import("x11_color.zig");
const colorpkg = @This();
/// The default palette.
pub const default: Palette = default: {
var result: Palette = undefined;
@@ -91,25 +90,32 @@ pub fn generate256Color(
skip: PaletteMask,
bg: RGB,
fg: RGB,
harmonious: bool
harmonious: bool,
) Palette {
// Convert the background, foreground, and 8 base theme colors into
// CIELAB space so that all interpolation is perceptually uniform.
const bg_lab: LAB = .fromRgb(bg);
const fg_lab: LAB = .fromRgb(fg);
const base8_lab: [8]LAB = base8: {
var base8: [8]LAB = .{
.fromRgb(bg),
LAB.fromRgb(base[1]),
LAB.fromRgb(base[2]),
LAB.fromRgb(base[3]),
LAB.fromRgb(base[4]),
LAB.fromRgb(base[5]),
LAB.fromRgb(base[6]),
.fromRgb(fg),
};
const is_light_theme = bg_lab.l > 50;
const invert = is_light_theme and !harmonious;
// For light themes (where the foreground is darker than the
// background), the cube's dark-to-light orientation is inverted
// relative to the base color mapping. When `harmonious` is false,
// swap bg and fg so the cube still runs from black (16) to
// white (231).
const is_light_theme = base8[7].l < base8[0].l;
const invert = is_light_theme and !harmonious;
if (invert) std.mem.swap(LAB, &base8[0], &base8[7]);
const base8_lab: [8]LAB = .{
if (invert) fg_lab else bg_lab,
LAB.fromRgb(base[1]),
LAB.fromRgb(base[2]),
LAB.fromRgb(base[3]),
LAB.fromRgb(base[4]),
LAB.fromRgb(base[5]),
LAB.fromRgb(base[6]),
if (invert) bg_lab else fg_lab,
break :base8 base8;
};
// Start from the base palette so indices 015 are preserved as-is.
@@ -937,7 +943,7 @@ test "generate256Color: base16 preserved" {
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
// The first 16 colors (base16) must remain unchanged.
for (0..16) |i| {
@@ -950,7 +956,7 @@ test "generate256Color: cube corners match base colors" {
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
// Index 16 is cube (0,0,0) which should equal bg.
try testing.expectEqual(bg, palette[16]);
@@ -959,12 +965,43 @@ test "generate256Color: cube corners match base colors" {
try testing.expectEqual(fg, palette[231]);
}
test "generate256Color: cube corners black/white with harmonious=false" {
const testing = std.testing;
const black = RGB{ .r = 0, .g = 0, .b = 0 };
const white = RGB{ .r = 255, .g = 255, .b = 255 };
// Dark theme: bg=black, fg=white.
const dark = generate256Color(default, .initEmpty(), black, white, false);
try testing.expectEqual(black, dark[16]);
try testing.expectEqual(white, dark[231]);
// Light theme: bg=white, fg=black. The bg/red swap ensures
// the cube still runs from black (16) to white (231).
const light = generate256Color(default, .initEmpty(), white, black, false);
try testing.expectEqual(black, light[16]);
try testing.expectEqual(white, light[231]);
}
test "generate256Color: light theme cube corners with harmonious=true" {
const testing = std.testing;
const white = RGB{ .r = 255, .g = 255, .b = 255 };
const black = RGB{ .r = 0, .g = 0, .b = 0 };
// harmonious=true skips the bg/fg swap, so the cube preserves the
// original orientation: (0,0,0)=bg=white, (5,5,5)=fg=black.
const palette = generate256Color(default, .initEmpty(), white, black, true);
try testing.expectEqual(white, palette[16]);
try testing.expectEqual(black, palette[231]);
}
test "generate256Color: grayscale ramp monotonic luminance" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
// The grayscale ramp (232255) should have monotonically increasing
// luminance from near-black to near-white.
@@ -988,7 +1025,7 @@ test "generate256Color: skip mask preserves original colors" {
skip.set(100);
skip.set(240);
const palette = generate256Color(default, skip, bg, fg);
const palette = generate256Color(default, skip, bg, fg, false);
try testing.expectEqual(default[20], palette[20]);
try testing.expectEqual(default[100], palette[100]);
try testing.expectEqual(default[240], palette[240]);
@@ -997,6 +1034,73 @@ test "generate256Color: skip mask preserves original colors" {
try testing.expect(!palette[21].eql(default[21]));
}
test "generate256Color: dark theme harmonious has no effect" {
const testing = std.testing;
// For a dark theme (fg lighter than bg), harmonious should not change
// the output because the inversion is only relevant for light themes.
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const normal = generate256Color(default, .initEmpty(), bg, fg, false);
const harmonious = generate256Color(default, .initEmpty(), bg, fg, true);
for (16..256) |i| {
try testing.expectEqual(normal[i], harmonious[i]);
}
}
test "generate256Color: light theme harmonious skips inversion" {
const testing = std.testing;
// For a light theme (fg darker than bg), harmonious=true skips the
// bg/red swap, producing different cube colors than harmonious=false.
const bg = RGB{ .r = 255, .g = 255, .b = 255 };
const fg = RGB{ .r = 0, .g = 0, .b = 0 };
const inverted = generate256Color(default, .initEmpty(), bg, fg, false);
const harmonious = generate256Color(default, .initEmpty(), bg, fg, true);
// Cube origin (0,0,0) at index 16: without harmonious, bg and red are
// swapped so it becomes the red base; with harmonious it stays as bg.
try testing.expectEqual(bg, harmonious[16]);
try testing.expect(!inverted[16].eql(bg));
// At least some cube colors should differ between the two modes.
var differ: usize = 0;
for (16..232) |i| {
if (!inverted[i].eql(harmonious[i])) differ += 1;
}
try testing.expect(differ > 0);
}
test "generate256Color: light theme harmonious grayscale ramp" {
const testing = std.testing;
const bg = RGB{ .r = 255, .g = 255, .b = 255 };
const fg = RGB{ .r = 0, .g = 0, .b = 0 };
// harmonious=false swaps bg/fg, so the ramp runs black→white (increasing).
{
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
var prev_lum: f64 = 0.0;
for (232..256) |i| {
const lum = palette[i].luminance();
try testing.expect(lum >= prev_lum);
prev_lum = lum;
}
}
// harmonious=true keeps original order, so the ramp runs white→black (decreasing).
{
const palette = generate256Color(default, .initEmpty(), bg, fg, true);
var prev_lum: f64 = 1.0;
for (232..256) |i| {
const lum = palette[i].luminance();
try testing.expect(lum <= prev_lum);
prev_lum = lum;
}
}
}
test "LAB.toRgb" {
const testing = std.testing;

View File

@@ -184,13 +184,7 @@ pub const DerivedConfig = struct {
break :generate;
}
break :palette terminalpkg.color.generate256Color(
config.palette.value,
config.palette.mask,
config.background.toTerminalRGB(),
config.foreground.toTerminalRGB(),
config.@"palette-harmonious"
);
break :palette terminalpkg.color.generate256Color(config.palette.value, config.palette.mask, config.background.toTerminalRGB(), config.foreground.toTerminalRGB(), config.@"palette-harmonious");
}
break :palette config.palette.value;