From c4c87f8c85fb7339c093538196847fc3d0eed3c8 Mon Sep 17 00:00:00 2001 From: Jake Stewart Date: Fri, 20 Feb 2026 07:46:16 +0800 Subject: [PATCH 1/2] make palette inversion opt-in --- src/config/Config.zig | 6 ++++++ src/terminal/color.zig | 29 ++++++++++++++++++++--------- src/termio/Termio.zig | 1 + 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index bb86b6bd5..b56aee004 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -808,6 +808,12 @@ 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. +/// +/// 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`) diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 483d65e28..1daa50107 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -1,9 +1,10 @@ -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; @@ -90,15 +91,25 @@ 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]); - break :base8 base8; + + const is_light_theme = bg_lab.l > 50; + const invert = is_light_theme and !harmonious; + + 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, }; // Start from the base palette so indices 0–15 are preserved as-is. @@ -115,10 +126,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 +158,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; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dee58dc22..80ab4d7c7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -189,6 +189,7 @@ pub const DerivedConfig = struct { config.palette.mask, config.background.toTerminalRGB(), config.foreground.toTerminalRGB(), + config.@"palette-harmonious" ); } From f66a84b18a44838831af5819cdad1ba85d9592e4 Mon Sep 17 00:00:00 2001 From: Jake Stewart Date: Fri, 20 Feb 2026 07:55:47 +0800 Subject: [PATCH 2/2] improve light theme detection --- src/config/Config.zig | 18 ++++- src/terminal/color.zig | 146 +++++++++++++++++++++++++++++++++++------ src/termio/Termio.zig | 8 +-- 3 files changed, 141 insertions(+), 31 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index b56aee004..df39835d1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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 16–231 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, diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 1daa50107..3b806f8b8 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -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 0–15 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 (232–255) 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; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 80ab4d7c7..092871af5 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -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;