WIP: Make palette inversion opt-in (#10877)

From #10554

> I can see there being space for some kind of new sequence that tells
the terminal that "hey, I want a semantic palette" or something, that is
better adjusted to themes automatically. But, I don't think we should
this by default.

> So my concrete proposal is to eliminate the inversion and bg => fg
ramp and do a more typical dark => light ramp (still taking into account
bg/fg, but keeping 16 black and 231 white). I think this is the only way
we can really make this a default on feature. I think this would address
all the negative reactions we've gotten to it.

This adds a new `palette-harmonious`, disabled by default, which allows
generated light themes to be inverted.
This commit is contained in:
Mitchell Hashimoto
2026-02-19 20:24:42 -08:00
committed by GitHub
3 changed files with 145 additions and 17 deletions

View File

@@ -808,6 +808,24 @@ palette: Palette = .{},
/// Available since: 1.3.0
@"palette-generate": bool = true,
/// 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,
/// The color of the cursor. If this is not set, a default will be chosen.
///
/// Direct colors can be specified as either hex (`#RRGGBB` or `RRGGBB`)

View File

@@ -90,14 +90,31 @@ pub fn generate256Color(
skip: PaletteMask,
bg: RGB,
fg: RGB,
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 = undefined;
for (0..8) |i| base8[i] = .fromRgb(base[i]);
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),
};
// 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]);
break :base8 base8;
};
@@ -115,10 +132,10 @@ pub fn generate256Color(
for (0..6) |ri| {
// R-axis corners: blend base colors along the red dimension.
const tr = @as(f32, @floatFromInt(ri)) / 5.0;
const c0: LAB = .lerp(tr, bg_lab, base8_lab[1]);
const c0: LAB = .lerp(tr, base8_lab[0], base8_lab[1]);
const c1: LAB = .lerp(tr, base8_lab[2], base8_lab[3]);
const c2: LAB = .lerp(tr, base8_lab[4], base8_lab[5]);
const c3: LAB = .lerp(tr, base8_lab[6], fg_lab);
const c3: LAB = .lerp(tr, base8_lab[6], base8_lab[7]);
for (0..6) |gi| {
// G-axis edges: blend the R-interpolated corners along green.
const tg = @as(f32, @floatFromInt(gi)) / 5.0;
@@ -147,7 +164,7 @@ pub fn generate256Color(
for (0..24) |i| {
const t = @as(f32, @floatFromInt(i + 1)) / 25.0;
if (!skip.isSet(idx)) {
const c: LAB = .lerp(t, bg_lab, fg_lab);
const c: LAB = .lerp(t, base8_lab[0], base8_lab[7]);
result[idx] = c.toRgb();
}
idx += 1;
@@ -926,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| {
@@ -939,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]);
@@ -948,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.
@@ -977,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]);
@@ -986,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,12 +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(),
);
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;