Add quick theming tool.

This commit is contained in:
Greyson Parrelli
2026-08-24 16:45:21 -04:00
parent 407dd81512
commit 071522f67c
7 changed files with 1367 additions and 77 deletions
+237 -16
View File
@@ -18,8 +18,14 @@
//! to set the same colour twice — which is also why every row is re-read after
//! any change, not just the row that changed.
//!
//! The section starts collapsed. It is thirty-nine swatches, and a settings page
//! that opens on all of them buries the two settings most people came for.
//! The swatches start collapsed, and the two controls above them do not. That
//! split is the point of the base colour: thirty-nine swatches is a fair way to
//! *correct* a theme and a miserable way to *choose* one, so the path that is
//! always on offer is "pick a colour, get a palette" (`tint.zig`), and the
//! wall of swatches is behind a disclosure for the times that isn't enough. The
//! two compose — a swatch that has been set by hand outranks the generated
//! colour underneath it — which is why picking a new base does not clear the
//! corrections someone made to the last one.
const std = @import("std");
const gdk = @import("gdk");
@@ -75,6 +81,17 @@ body: *gtk.Box,
/// Says which scheme is being edited, and is rewritten when that changes.
scheme_hint: *gtk.Label,
/// The colour the rest of the palette is generated from, the hex it currently
/// stands at, and the button that drops it. Assigned in `build`, since the
/// swatch has to be handed its dialog at construction.
base: *gtk.ColorDialogButton,
base_hex: *gtk.Label,
base_clear: *gtk.Button,
/// How far apart to spread the colours built from the base. Insensitive until
/// there is a base, since on its own it has nothing to spread.
contrast: *gtk.Scale,
/// Puts the whole scheme back to its defaults.
reset: *gtk.Button,
@@ -101,6 +118,12 @@ pub fn create(alloc: std.mem.Allocator, opts: Options) !*PaletteEditor {
.root = gtk.Box.new(.vertical, 10),
.body = gtk.Box.new(.vertical, 14),
.scheme_hint = gtk.Label.new(null),
.base = undefined,
.base_hex = gtk.Label.new(null),
.base_clear = gtk.Button.newFromIconName("edit-undo-symbolic"),
// The step is what the arrow keys move by; the range is the one
// `palette.Tint.contrast` is defined over.
.contrast = gtk.Scale.newWithRange(.horizontal, -1, 1, 0.05),
.reset = gtk.Button.newWithLabel("Reset every colour"),
.rows = undefined,
.groups = undefined,
@@ -137,17 +160,24 @@ pub fn refresh(self: *PaletteEditor) void {
fn build(self: *PaletteEditor) void {
self.root.append(self.buildHeader());
// Above the base colour rather than inside the disclosure with the
// swatches, because it is true of the base colour too: both palettes can be
// given one, and this is the only thing on the page that says which of them
// the control below is about to change.
self.scheme_hint.setXalign(0);
self.scheme_hint.setWrap(1);
self.scheme_hint.as(gtk.Widget).addCssClass("playpen-dialog-hint");
self.body.append(self.scheme_hint.as(gtk.Widget));
self.root.append(self.scheme_hint.as(gtk.Widget));
self.root.append(self.buildBase());
self.buildGroups();
self.reset.as(gtk.Widget).addCssClass("flat");
self.reset.as(gtk.Widget).setHalign(.start);
self.reset.as(gtk.Widget).setTooltipText(
"Put every colour in this scheme back to the one Playpen ships with",
"Put every colour in this scheme back to the one Playpen ships with, " ++
"and drop the base colour above",
);
_ = gtk.Button.signals.clicked.connect(self.reset, *PaletteEditor, &onReset, self, .{});
self.body.append(self.reset.as(gtk.Widget));
@@ -202,6 +232,118 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget {
return row.as(gtk.Widget);
}
/// The two controls that are always on offer: the colour the palette is built
/// out of, and how far apart to spread what gets built.
///
/// Outside the disclosure below on purpose. This is the answer for someone who
/// wants the app to be green, and the thirty-nine swatches are the answer for
/// someone who has already got there and wants the "waiting on you" amber a
/// shade warmer — putting the first behind the same toggle as the second would
/// hide the easy path behind the hard one.
fn buildBase(self: *PaletteEditor) *gtk.Widget {
const group = gtk.Box.new(.vertical, 6);
// The same class the swatch sections below carry, which is where the
// stylesheet hangs the "a colour button is a rectangle of the colour, not a
// button holding one" rule. This row has a colour button in it and wants to
// look like the ones under Customise.
group.as(gtk.Widget).addCssClass("playpen-color-group");
// ---- the colour ----
const row = gtk.Box.new(.horizontal, 8);
row.as(gtk.Widget).addCssClass("playpen-color-row");
const label = gtk.Label.new("Base colour");
label.setXalign(0);
label.as(gtk.Widget).setHexpand(1);
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
row.append(label.as(gtk.Widget));
self.base_hex.as(gtk.Widget).addCssClass("playpen-color-hex");
row.append(self.base_hex.as(gtk.Widget));
const dialog = gtk.ColorDialog.new();
dialog.setWithAlpha(0);
dialog.setModal(1);
dialog.setTitle("Base colour");
self.base = gtk.ColorDialogButton.new(dialog);
self.base.as(gtk.Widget).setValign(.center);
self.base.as(gtk.Widget).setSizeRequest(52, 24);
_ = gobject.Object.signals.notify.connect(
self.base,
*PaletteEditor,
&onBasePicked,
self,
.{ .detail = "rgba" },
);
row.append(self.base.as(gtk.Widget));
self.base_clear.as(gtk.Widget).addCssClass("flat");
self.base_clear.as(gtk.Widget).setTooltipText("Back to Playpen's own colours");
_ = gtk.Button.signals.clicked.connect(
self.base_clear,
*PaletteEditor,
&onBaseCleared,
self,
.{},
);
row.append(self.base_clear.as(gtk.Widget));
group.append(row.as(gtk.Widget));
group.append(hint(
"Pick one and the rest follows: a very dark version of it behind the window, " ++
"lighter ones for the panes and the text on them, and the colour itself as the accent. " ++
"Anything you set under Customise stays where you put it.",
));
// ---- how far apart ----
const contrast_row = gtk.Box.new(.horizontal, 8);
contrast_row.as(gtk.Widget).addCssClass("playpen-color-row");
contrast_row.as(gtk.Widget).setTooltipText(
"How far apart to spread the colours built from the base.\n" ++
"Starker pulls the backdrop darker and the text brighter; softer draws them together.",
);
const contrast_label = gtk.Label.new("Contrast");
contrast_label.setXalign(0);
contrast_label.as(gtk.Widget).addCssClass("playpen-dialog-label");
contrast_row.append(contrast_label.as(gtk.Widget));
self.contrast.as(gtk.Widget).setHexpand(1);
self.contrast.as(gtk.Widget).addCssClass("playpen-contrast");
// No fill behind the handle. GTK draws one from the low end of the range,
// which would say the setting counts up from "soft"; it counts out from the
// middle, and the mark there is what says so.
self.contrast.setHasOrigin(0);
// No number beside it: the value is a position on a scale with no unit, and
// "0.35" says less about what it will look like than the slider already does.
self.contrast.setDrawValue(0);
self.contrast.as(gtk.Range).setIncrements(0.05, 0.25);
self.contrast.as(gtk.Range).setRoundDigits(2);
// The unlabelled middle mark is what makes the shipped spacing findable
// again after a drag: GTK snaps the handle to a mark it passes near.
self.contrast.addMark(-1, .bottom, "Soft");
self.contrast.addMark(0, .bottom, null);
self.contrast.addMark(1, .bottom, "Stark");
_ = gtk.Range.signals.value_changed.connect(
self.contrast,
*PaletteEditor,
&onContrastChanged,
self,
.{},
);
contrast_row.append(self.contrast.as(gtk.Widget));
group.append(contrast_row.as(gtk.Widget));
return group.as(gtk.Widget);
}
/// One section per group, in the order the keys are declared: the surfaces, the
/// text on them, the accents, and the terminal last.
fn buildGroups(self: *PaletteEditor) void {
@@ -352,7 +494,7 @@ fn reload(self: *PaletteEditor) void {
defer self.updating = false;
const scheme = theme.currentScheme();
const overrides = Settings.get().colors.of(scheme);
const changes = Settings.get().colors.of(scheme);
var buf: [192]u8 = undefined;
const heading: [:0]const u8 = std.fmt.bufPrintZ(
@@ -363,10 +505,29 @@ fn reload(self: *PaletteEditor) void {
) catch "Changes apply as you make them.";
self.scheme_hint.setText(heading);
// With no base of its own, the swatch shows the accent this scheme is
// already painted with — so the first click on it starts from the theme in
// front of you rather than from whatever colour a picker opens on.
const base = if (changes.tint) |tint|
tint.base
else
palette.resolve(.accent, scheme, changes);
var base_rgba = toRgba(base);
self.base.setRgba(&base_rgba);
var base_hex: [7:0]u8 = undefined;
self.base_hex.setText(base.hex(&base_hex));
self.base_clear.as(gtk.Widget).setSensitive(@intFromBool(changes.tint != null));
self.contrast.as(gtk.Range).setValue(if (changes.tint) |tint| tint.contrast else 0);
self.contrast.as(gtk.Widget).setSensitive(@intFromBool(changes.tint != null));
for (&self.rows) |*row| {
// The colour, not the override: a terminal colour that is following the
// surface above it has to show the colour it is actually painted in.
const color = palette.resolve(row.key, scheme, overrides);
// surface above it, or any colour at all under a base, has to show the
// colour it is actually painted in.
const color = palette.resolve(row.key, scheme, changes);
var rgba = toRgba(color);
row.swatch.setRgba(&rgba);
@@ -377,17 +538,20 @@ fn reload(self: *PaletteEditor) void {
}
// Whether there is anything to revert *to*, which is a different
// question: an inherited colour is not one that has been set.
// question: neither an inherited colour nor a generated one is a colour
// that has been set.
if (row.revert) |button| {
button.as(gtk.Widget).setSensitive(@intFromBool(overrides.get(row.key) != null));
button.as(gtk.Widget).setSensitive(@intFromBool(changes.overrides.get(row.key) != null));
}
}
for (&self.groups) |*header| {
header.reset.as(gtk.Widget).setSensitive(@intFromBool(groupIsSet(header.group, overrides)));
header.reset.as(gtk.Widget).setSensitive(
@intFromBool(groupIsSet(header.group, &changes.overrides)),
);
}
self.reset.as(gtk.Widget).setSensitive(@intFromBool(palette.anySet(overrides)));
self.reset.as(gtk.Widget).setSensitive(@intFromBool(changes.anySet()));
}
fn groupIsSet(group: palette.Group, overrides: *const palette.Overrides) bool {
@@ -413,6 +577,19 @@ fn applied(self: *PaletteEditor) void {
self.on_report(self.ctx, null);
}
/// Show the change and leave the file for later.
///
/// For the contrast slider alone. Every other control here settles on a value
/// once — a colour dialog reports when it is dismissed, a button when it is
/// clicked — but a slider reports on every pixel of a drag, and writing the
/// settings file at that rate would be an fsync per frame of an animation. The
/// settings page writes on close, so nothing is lost by waiting; see the note at
/// the top of `SettingsDialog`.
fn appliedLive(self: *PaletteEditor) void {
appearance.refresh();
self.reload();
}
// -------------------------------------------------------------------------
// Handlers
@@ -434,33 +611,77 @@ fn onColorPicked(
// rows were last filled from would put the colour in the palette that isn't
// on screen.
const scheme = theme.currentScheme();
Settings.get().colors.of(scheme).set(row.key, fromRgba(swatch.getRgba()));
Settings.get().colors.of(scheme).overrides.set(row.key, fromRgba(swatch.getRgba()));
self.applied();
}
fn onRevert(_: *gtk.Button, row: *Row) callconv(.c) void {
const self = row.editor;
Settings.get().colors.of(theme.currentScheme()).set(row.key, null);
Settings.get().colors.of(theme.currentScheme()).overrides.set(row.key, null);
self.applied();
}
fn onResetGroup(_: *gtk.Button, header: *GroupHeader) callconv(.c) void {
const self = header.editor;
const overrides = Settings.get().colors.of(theme.currentScheme());
const changes = Settings.get().colors.of(theme.currentScheme());
for (std.enums.values(palette.Key)) |key| {
if (key.group() == header.group) overrides.set(key, null);
if (key.group() == header.group) changes.overrides.set(key, null);
}
self.applied();
}
fn onReset(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void {
Settings.get().colors.of(theme.currentScheme()).* = palette.no_overrides;
Settings.get().colors.of(theme.currentScheme()).* = .{};
self.applied();
}
fn onBasePicked(
swatch: *gtk.ColorDialogButton,
_: *gobject.ParamSpec,
self: *PaletteEditor,
) callconv(.c) void {
if (self.updating) return;
// The scheme is read here rather than remembered, for the reason
// `onColorPicked` gives.
const changes = Settings.get().colors.of(theme.currentScheme());
changes.tint = .{
.base = fromRgba(swatch.getRgba()),
// Carried over rather than reset: trying a second base against a spread
// you have already settled on is the common move, and having the slider
// jump back to the middle every time would make it impossible.
.contrast = if (changes.tint) |tint| tint.contrast else 0,
};
self.applied();
}
fn onBaseCleared(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void {
// Only the generated palette goes. Colours set by hand were set against
// what was underneath them and are still what their owner asked for, so
// dropping those too is `onReset`'s job and is labelled as such.
Settings.get().colors.of(theme.currentScheme()).tint = null;
self.applied();
}
fn onContrastChanged(scale: *gtk.Scale, self: *PaletteEditor) callconv(.c) void {
if (self.updating) return;
const changes = Settings.get().colors.of(theme.currentScheme());
// The slider is insensitive without a base, so this is unreachable in the
// normal way — but the property can still be set from a screen reader or a
// theme change landing mid-drag, and a contrast with nothing to spread is
// not worth a repaint.
if (changes.tint) |*tint| {
tint.contrast = @floatCast(scale.as(gtk.Range).getValue());
self.appliedLive();
}
}
// -------------------------------------------------------------------------
// Small helpers
+179 -24
View File
@@ -5,16 +5,18 @@
//! setting is not also the day we invent a format.
//!
//! The settings are three different shapes. The colour scheme is one word. The
//! **palette overrides** are a colour per name per scheme, and only for the
//! names someone has actually changed. The **startup tabs** are a list: which
//! **palette changes** are a base colour per scheme, plus a colour per name for
//! the names someone has gone on to change by hand. The **startup tabs** are a
//! list: which
//! saved layout to open, what to fill its parameters in with, and what to call
//! the tab — one entry per tab, opened in order at launch. That list is why this
//! module holds an arena: every string in it is owned here, and replacing the
//! list frees the previous one in a single stroke rather than tracking each
//! field.
//!
//! The overrides deliberately stay out of that arena. They are a fixed-size
//! array of optionals, which needs no allocation at all — and, more to the
//! The palette changes deliberately stay out of that arena. They are a base
//! colour and a fixed-size array of optionals, which needs no allocation at
//! all — and, more to the
//! point, `setStartup` replaces the arena wholesale, so anything colour-shaped
//! living in it would be freed by an unrelated edit to the startup list. The
//! **shortcut overrides** are the same shape for the same reason: a chord is a
@@ -71,16 +73,17 @@ pub const Theme = enum {
}
};
/// The palette colours someone has changed, per scheme.
/// What someone has done to the palette, per scheme: the colour it is generated
/// from, and the individual colours they have set by hand.
///
/// Both schemes are kept, not just the one in force: the editor changes the
/// palette you can see, and pinning the app light for an afternoon shouldn't
/// cost you the dark palette you spent an evening on.
pub const Colors = struct {
dark: palette.Overrides = palette.no_overrides,
light: palette.Overrides = palette.no_overrides,
dark: palette.Changes = .{},
light: palette.Changes = .{},
pub fn of(self: *Colors, scheme: palette.Scheme) *palette.Overrides {
pub fn of(self: *Colors, scheme: palette.Scheme) *palette.Changes {
return switch (scheme) {
.dark => &self.dark,
.light => &self.light,
@@ -245,6 +248,8 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
if (root.get("colors")) |value| self.parseColors(value);
if (root.get("tint")) |value| self.parseTint(value);
if (root.get("shortcuts")) |value| self.parseShortcuts(value);
if (root.get("startup")) |value| try self.parseStartup(value);
@@ -299,11 +304,68 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
continue;
};
into.set(key, color);
into.overrides.set(key, color);
}
}
}
/// Read the generated palettes: `{"dark": {"base": "#3a76f0", "contrast": 0.5}}`.
///
/// A scheme without a `base` has no generated palette at all, whatever else the
/// entry says — the contrast is how far apart to spread the colours built from
/// the base, so on its own it describes nothing. That is also why a malformed
/// contrast costs only the contrast: the base is the setting, and dropping the
/// whole theme over one bad number would be the wrong trade.
fn parseTint(self: *Settings, raw: std.json.Value) void {
const obj = switch (raw) {
.object => |o| o,
else => {
std.log.warn("settings: \"tint\" is not an object; ignoring it", .{});
return;
},
};
for (std.enums.values(palette.Scheme)) |scheme| {
const entry = switch (obj.get(@tagName(scheme)) orelse continue) {
.object => |o| o,
else => {
std.log.warn("settings: tint.{s} is not an object; ignoring it", .{@tagName(scheme)});
continue;
},
};
const text = switch (entry.get("base") orelse continue) {
.string => |t| t,
else => {
std.log.warn("settings: tint.{s}.base is not a string", .{@tagName(scheme)});
continue;
},
};
const base = palette.Rgb.parse(text) orelse {
std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text});
continue;
};
// Clamped rather than rejected: the range is a detail of what the slider
// can express, and a file that says 2 plainly means "as stark as it
// goes".
const contrast: f32 = switch (entry.get("contrast") orelse std.json.Value{ .float = 0 }) {
.float => |f| @floatCast(f),
.integer => |i| @floatFromInt(i),
else => blk: {
std.log.warn("settings: tint.{s}.contrast is not a number", .{@tagName(scheme)});
break :blk 0;
},
};
self.colors.of(scheme).tint = .{
.base = base,
.contrast = std.math.clamp(contrast, -1, 1),
};
}
}
/// Read the rebound shortcuts: `{"focus_pane_left": "ctrl+shift+h", …}`.
///
/// A value is either one chord or a list of them, because most actions want one
@@ -545,7 +607,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
var started = false;
for (std.enums.values(palette.Key)) |key| {
const color = colors.get(key) orelse continue;
const color = colors.overrides.get(key) orelse continue;
if (!started) {
try json.objectField(@tagName(scheme));
try json.beginObject();
@@ -559,6 +621,26 @@ fn serialize(self: *Settings) SaveError![]u8 {
}
try json.endObject();
// And the base colour each scheme's palette is generated from, for the
// schemes that have one. Written after the overrides rather than before
// because that is the order they apply in, and a settings file is read by
// people as well as by this.
try json.objectField("tint");
try json.beginObject();
for (std.enums.values(palette.Scheme)) |scheme| {
const tint = self.colors.of(scheme).tint orelse continue;
var buf: [7:0]u8 = undefined;
try json.objectField(@tagName(scheme));
try json.beginObject();
try json.objectField("base");
try json.write(tint.base.hex(&buf));
try json.objectField("contrast");
try json.write(tint.contrast);
try json.endObject();
}
try json.endObject();
// Only the shortcuts that have been rebound, for the same reasons as the
// colours — and with the same consequence, which is the one that matters
// here: saving rewrites the whole file, so a hand-edited shortcut has to
@@ -660,14 +742,14 @@ test "palette overrides are read per scheme" {
\\}}
);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.get(.accent).?);
try std.testing.expectEqual(palette.Rgb.parse("#112233").?, settings.colors.dark.get(.ansi_red).?);
try std.testing.expectEqual(palette.Rgb.parse("#fafafa").?, settings.colors.light.get(.sidebar).?);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.overrides.get(.accent).?);
try std.testing.expectEqual(palette.Rgb.parse("#112233").?, settings.colors.dark.overrides.get(.ansi_red).?);
try std.testing.expectEqual(palette.Rgb.parse("#fafafa").?, settings.colors.light.overrides.get(.sidebar).?);
// A scheme that says nothing about a colour leaves it at its default, which
// is what an absent override means everywhere else.
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.light.get(.accent));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.get(.sidebar));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.light.overrides.get(.accent));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
}
test "a colour we can't read costs that colour and nothing else" {
@@ -683,13 +765,13 @@ test "a colour we can't read costs that colour and nothing else" {
\\}}}
);
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.get(.border));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.get(.sidebar));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.border));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
// The whole file survives, including the theme above the colours and the
// one colour in the list that was well formed.
try std.testing.expectEqual(Theme.dark, settings.theme);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.get(.accent).?);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.overrides.get(.accent).?);
}
test "colors is written even when nothing has been changed" {
@@ -706,9 +788,9 @@ test "only the changed colours are written, and they come back" {
var settings = forTesting();
defer settings.arena.deinit();
settings.colors.dark.set(.accent, palette.Rgb.parse("#ff8800").?);
settings.colors.dark.set(.ansi_bright_white, palette.Rgb.parse("#010203").?);
settings.colors.light.set(.text, palette.Rgb.parse("#040506").?);
settings.colors.dark.overrides.set(.accent, palette.Rgb.parse("#ff8800").?);
settings.colors.dark.overrides.set(.ansi_bright_white, palette.Rgb.parse("#010203").?);
settings.colors.light.overrides.set(.text, palette.Rgb.parse("#040506").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
@@ -726,11 +808,84 @@ test "only the changed colours are written, and they come back" {
try read_back.parse(text);
for (std.enums.values(palette.Key)) |key| {
try std.testing.expectEqual(settings.colors.dark.get(key), read_back.colors.dark.get(key));
try std.testing.expectEqual(settings.colors.light.get(key), read_back.colors.light.get(key));
try std.testing.expectEqual(settings.colors.dark.overrides.get(key), read_back.colors.dark.overrides.get(key));
try std.testing.expectEqual(settings.colors.light.overrides.get(key), read_back.colors.light.overrides.get(key));
}
}
test "a base colour and its contrast survive the file" {
var settings = forTesting();
defer settings.arena.deinit();
settings.colors.dark.tint = .{ .base = palette.Rgb.parse("#d2691e").?, .contrast = 0.45 };
settings.colors.dark.overrides.set(.accent, palette.Rgb.parse("#ff8800").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
var read_back = forTesting();
defer read_back.arena.deinit();
try read_back.parse(text);
try std.testing.expect(read_back.colors.dark.tint.?.eql(settings.colors.dark.tint.?));
// The scheme that has no base still has none, and the override the base sits
// under came back with it: the two are separate settings and a round trip
// that merged them would be one of them lost.
try std.testing.expectEqual(@as(?palette.Tint, null), read_back.colors.light.tint);
try std.testing.expectEqual(
palette.Rgb.parse("#ff8800").?,
read_back.colors.dark.overrides.get(.accent).?,
);
}
test "tint is written even when no scheme has one" {
var settings = forTesting();
defer settings.arena.deinit();
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"tint\": {}") != null);
}
test "a tint we can't read costs that tint and nothing else" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"theme": "dark", "tint": {
\\ "dark": {"base": "rebeccapurple", "contrast": 0.5},
\\ "light": {"contrast": 0.5}
\\}}
);
// A base that doesn't parse and a scheme that never named one both come out
// the same way: no generated palette, and a file that is otherwise intact.
try std.testing.expectEqual(@as(?palette.Tint, null), settings.colors.dark.tint);
try std.testing.expectEqual(@as(?palette.Tint, null), settings.colors.light.tint);
try std.testing.expectEqual(Theme.dark, settings.theme);
}
test "a contrast outside the range is pulled into it" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"tint": {
\\ "dark": {"base": "#3a76f0", "contrast": 4},
\\ "light": {"base": "#3a76f0", "contrast": "very"}
\\}}
);
try std.testing.expectEqual(@as(f32, 1), settings.colors.dark.tint.?.contrast);
// An unreadable contrast leaves the base standing at the neutral spread,
// which is what a base with no contrast beside it already means.
try std.testing.expectEqual(@as(f32, 0), settings.colors.light.tint.?.contrast);
try std.testing.expectEqual(palette.Rgb.parse("#3a76f0").?, settings.colors.light.tint.?.base);
}
test "a shortcut can be given one chord or several" {
var settings = forTesting();
defer settings.arena.deinit();
@@ -825,7 +980,7 @@ test "a scheme with nothing changed is left out of the file entirely" {
var settings = forTesting();
defer settings.arena.deinit();
settings.colors.dark.set(.accent, palette.Rgb.parse("#ff8800").?);
settings.colors.dark.overrides.set(.accent, palette.Rgb.parse("#ff8800").?);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
+260
View File
@@ -0,0 +1,260 @@
//! sRGB in, sRGB out, with a perceptual space in the middle.
//!
//! This exists for `tint.zig`, which builds a whole palette out of one colour
//! and therefore has to answer questions like "the same hue, a third as bright"
//! and "one step lighter than that". Those questions have no good answer in RGB
//! — halving the channels of a mid blue gives a navy, halving the channels of a
//! mid yellow gives an olive, and the two do not look like they moved by the
//! same amount. HSL is no better: its lightness is the midpoint of the largest
//! and smallest channel, so a pure yellow and a pure blue are both "50% light"
//! when one of them is nearly white and the other is nearly black.
//!
//! OKLab is a fit to what people actually report seeing, and its lightness axis
//! is even enough that a ramp built by stepping it reads as an even ramp. The
//! polar form — lightness, chroma, hue — is what a palette is really made of:
//! hold the hue, walk the lightness, and the surfaces of a theme fall out.
//!
//! The one thing OKLCh will happily do that sRGB will not is name a colour that
//! does not exist on a monitor — a fully saturated yellow at the lightness of a
//! midtone, say. `toRgb` deals with that by giving up chroma rather than
//! lightness or hue: the result is the most colourful version of the colour that
//! can actually be shown, which is what "as close as the screen gets" should
//! mean for a palette. Clipping the channels instead would shift the hue, and a
//! generated theme whose reds drift orange as they darken looks broken in a way
//! that a slightly duller red does not.
//!
//! The matrices are Björn Ottosson's, unchanged.
const std = @import("std");
const palette = @import("palette.zig");
const Rgb = palette.Rgb;
/// A colour in OKLCh: how light, how colourful, and which colour.
pub const Lch = struct {
/// Perceptual lightness. 0 is black, 1 is white, and 0.5 looks like a
/// midtone rather than merely being one arithmetically.
l: f32,
/// Colourfulness. 0 is a grey; how high it can go before leaving sRGB
/// depends on both the lightness and the hue, and is around 0.32 at best.
c: f32,
/// Hue angle in degrees. Roughly: 30 red, 100 yellow, 145 green, 195 cyan,
/// 260 blue, 330 magenta.
h: f32,
};
pub fn fromRgb(rgb: Rgb) Lch {
const lin: Linear = .{
.r = decode(rgb.r),
.g = decode(rgb.g),
.b = decode(rgb.b),
};
const lab = lin.toLab();
const c = std.math.hypot(lab.a, lab.b);
return .{
.l = lab.l,
.c = c,
// A grey has no hue to report, and `atan2` on two zeroes is entitled to
// say anything. Zero is as good as any other answer and is at least the
// same one every time, which matters: the palette generator reads a hue
// off the base colour and gives it to forty other colours, and a grey
// base that produced a different hue on each launch would be a theme
// that changed colour when you restarted the app.
.h = if (c < 1e-6) 0 else std.math.radiansToDegrees(std.math.atan2(lab.b, lab.a)),
};
}
/// The nearest colour a screen can show, giving up chroma before anything else.
pub fn toRgb(lch: Lch) Rgb {
const l = std.math.clamp(lch.l, 0, 1);
const rad = std.math.degreesToRadians(lch.h);
const c = fit(l, @max(lch.c, 0), rad);
const lin = Lab.at(l, c, rad).toLinear();
return .{
.r = encode(lin.r),
.g = encode(lin.g),
.b = encode(lin.b),
};
}
/// The largest chroma at or below `c` that stays inside sRGB, to within a
/// rounding error of the 8-bit channels this is on its way to.
///
/// A bisection rather than a formula because the sRGB gamut boundary in OKLab
/// is not one: it is the image of a cube through a cube root, and the closed
/// forms for it are approximations with their own error. Twelve halvings of a
/// range that is at most 1.0 wide lands well inside a 1/255 step, and this runs
/// forty times when someone drags a colour picker.
fn fit(l: f32, c: f32, rad: f32) f32 {
if (Lab.at(l, c, rad).inGamut()) return c;
var lo: f32 = 0;
var hi: f32 = c;
for (0..12) |_| {
const mid = (lo + hi) / 2;
if (Lab.at(l, mid, rad).inGamut()) lo = mid else hi = mid;
}
return lo;
}
// -------------------------------------------------------------------------
// The two conversions, and the linear-light stage between them.
/// Light as the eye's cone responses model it: perceptual lightness, and two
/// opponent axes that carry the hue and how much of it there is.
const Lab = struct {
l: f32,
a: f32,
b: f32,
fn at(l: f32, c: f32, rad: f32) Lab {
return .{ .l = l, .a = c * @cos(rad), .b = c * @sin(rad) };
}
fn toLinear(self: Lab) Linear {
const l_ = self.l + 0.3963377774 * self.a + 0.2158037573 * self.b;
const m_ = self.l - 0.1055613458 * self.a - 0.0638541728 * self.b;
const s_ = self.l - 0.0894841775 * self.a - 1.2914855480 * self.b;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
return .{
.r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
.g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
.b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
};
}
/// Whether this colour is one a screen can show. The tolerance is a hair
/// over half of an 8-bit step in linear light near the top of the range, so
/// a colour that is out of gamut only by the arithmetic isn't hunted down
/// by the bisection above for no visible gain.
fn inGamut(self: Lab) bool {
const lin = self.toLinear();
const tolerance = 1e-4;
for ([_]f32{ lin.r, lin.g, lin.b }) |channel| {
if (channel < -tolerance or channel > 1 + tolerance) return false;
}
return true;
}
};
/// sRGB with the display transfer function taken off, which is the only form in
/// which the channels can be mixed arithmetically.
const Linear = struct {
r: f32,
g: f32,
b: f32,
fn toLab(self: Linear) Lab {
const l = 0.4122214708 * self.r + 0.5363325363 * self.g + 0.0514459929 * self.b;
const m = 0.2119034982 * self.r + 0.6806995451 * self.g + 0.1073969566 * self.b;
const s = 0.0883024619 * self.r + 0.2817188376 * self.g + 0.6299787005 * self.b;
const l_ = std.math.cbrt(l);
const m_ = std.math.cbrt(m);
const s_ = std.math.cbrt(s);
return .{
.l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
.a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
.b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
};
}
};
fn decode(channel: u8) f32 {
const v = @as(f32, @floatFromInt(channel)) / 255.0;
if (v <= 0.04045) return v / 12.92;
return std.math.pow(f32, (v + 0.055) / 1.055, 2.4);
}
fn encode(channel: f32) u8 {
const v = std.math.clamp(channel, 0, 1);
const encoded = if (v <= 0.0031308)
v * 12.92
else
1.055 * std.math.pow(f32, v, 1.0 / 2.4) - 0.055;
return @intFromFloat(@round(std.math.clamp(encoded, 0, 1) * 255.0));
}
// -------------------------------------------------------------------------
test "a colour survives the round trip" {
// Every hue family, plus the two ends and a grey, since those are the cases
// where the hue is undefined and the transfer function is at its steepest.
const cases = [_][]const u8{
"#000000", "#ffffff", "#808080", "#3a76f0", "#d2691e",
"#12805a", "#f2717b", "#070c15", "#dde6f4", "#8a5a00",
};
for (cases) |text| {
const rgb = Rgb.parse(text).?;
const back = toRgb(fromRgb(rgb));
// One 8-bit step of slack: the trip is through a cube root and back.
var buf: [7:0]u8 = undefined;
const drift = @max(
@abs(@as(i16, back.r) - @as(i16, rgb.r)),
@max(
@abs(@as(i16, back.g) - @as(i16, rgb.g)),
@abs(@as(i16, back.b) - @as(i16, rgb.b)),
),
);
if (drift > 1) {
std.debug.print("{s} came back as {s}\n", .{ text, back.hex(&buf) });
return error.RoundTripDrifted;
}
}
}
test "black and white are where they should be" {
try std.testing.expectApproxEqAbs(@as(f32, 0), fromRgb(.{ .r = 0, .g = 0, .b = 0 }).l, 1e-4);
try std.testing.expectApproxEqAbs(@as(f32, 1), fromRgb(.{ .r = 255, .g = 255, .b = 255 }).l, 1e-4);
// A grey has no hue, and says so rather than saying whatever `atan2` makes
// of two zeroes.
const grey = fromRgb(.{ .r = 128, .g = 128, .b = 128 });
try std.testing.expectApproxEqAbs(@as(f32, 0), grey.c, 1e-3);
try std.testing.expectEqual(@as(f32, 0), grey.h);
}
test "an impossible colour gives up chroma, not hue" {
// A fully saturated yellow at the lightness of a midtone: nothing like it
// exists in sRGB, and asking for it has to produce *something*.
const asked: Lch = .{ .l = 0.5, .c = 0.3, .h = 100 };
const got = fromRgb(toRgb(asked));
try std.testing.expectApproxEqAbs(asked.l, got.l, 0.01);
try std.testing.expectApproxEqAbs(asked.h, got.h, 1.5);
try std.testing.expect(got.c < asked.c);
// And it is still as colourful as sRGB allows, rather than having been
// rounded down to something safe: pushing it back up leaves the gamut.
try std.testing.expect(!Lab.at(asked.l, got.c + 0.01, std.math.degreesToRadians(asked.h)).inGamut());
}
test "lightness is even enough to build a ramp on" {
// The point of the whole module: equal steps in `l` have to look like equal
// steps, whatever the hue. What is checked here is the weaker property that
// makes that possible — the steps come back out the size they went in, for
// hues whose RGB representations are nothing alike.
for ([_]f32{ 30, 100, 145, 260, 330 }) |hue| {
var previous: f32 = 0;
var step: f32 = 0.2;
while (step <= 0.8) : (step += 0.2) {
const back = fromRgb(toRgb(.{ .l = step, .c = 0.05, .h = hue })).l;
try std.testing.expectApproxEqAbs(step, back, 0.01);
try std.testing.expect(back > previous);
previous = back;
}
}
}
+113 -29
View File
@@ -19,10 +19,21 @@
//! "reset" and "you have changed this one" answerable at all, and it means a
//! default that gets retuned in a later version reaches everyone who never
//! touched it, rather than only the people who had never opened the editor.
//!
//! Under the overrides there is one more layer: a `Tint`, which is a single
//! colour that `tint.zig` builds an entire palette out of. It stands in for the
//! defaults rather than beside them, so the three layers read outward from the
//! most specific — this colour, then the colour built from your base, then the
//! colour Playpen ships. That ordering is what lets someone pick a base and then
//! fix the two swatches they didn't like without the fix being undone the next
//! time they nudge the base, and it is why `Changes` is a struct rather than the
//! bare override array it used to be.
const std = @import("std");
const vt = @import("ghostty-vt");
const tint = @import("tint.zig");
pub const Rgb = struct {
r: u8,
g: u8,
@@ -356,27 +367,66 @@ pub const Overrides = std.enums.EnumArray(Key, ?Rgb);
pub const no_overrides: Overrides = .initFill(null);
/// One colour, and how far apart to spread everything built around it. See
/// `tint.zig` for what "built around it" means.
pub const Tint = struct {
/// The colour the palette is generated from. It is also, near enough, what
/// `accent` comes out as — see `tint.derive`.
base: Rgb,
/// How stark to make the generated palette, from -1 (the surfaces and the
/// text on them draw together) through 0 (the spacing the shipped palette
/// uses) to 1 (they spread as far as they can go).
contrast: f32 = 0,
pub fn eql(self: Tint, other: Tint) bool {
return self.base.eql(other.base) and self.contrast == other.contrast;
}
};
/// Everything someone has done to one scheme's palette: the base colour it is
/// generated from, if any, and the individual colours they have set by hand.
///
/// Both, and in that order, rather than either — see the note at the top of the
/// file. A base is a starting point and the overrides are the corrections to it,
/// so a base that wiped the corrections would make the two settings fight.
pub const Changes = struct {
tint: ?Tint = null,
overrides: Overrides = no_overrides,
/// Whether anything in this scheme has been changed at all. What the
/// editor's "reset everything" button is greyed out by.
pub fn anySet(self: *const Changes) bool {
if (self.tint != null) return true;
for (std.enums.values(Key)) |key| {
if (self.overrides.get(key) != null) return true;
}
return false;
}
};
/// A scheme nobody has touched. Named so that the places that resolve a colour
/// without any settings to hand — the terminal renderer's compile-time initial
/// palette, and the tests — have something to point at.
pub const unchanged: Changes = .{};
/// The colour to paint with: the override if there is one, else what the key
/// inherits (overrides and all), else the default.
/// inherits (overrides and all), else the colour a base generates for it, else
/// the default.
///
/// The inheritance is resolved through the overrides rather than around them,
/// which is the point of it — recolour `surface` and the terminal background
/// follows, without anyone having had to set both.
pub fn resolve(key: Key, scheme: Scheme, overrides: *const Overrides) Rgb {
if (overrides.get(key)) |color| return color;
if (key.inherits()) |from| return resolve(from, scheme, overrides);
/// follows, without anyone having had to set both. It is resolved *before* the
/// generated palette for the same reason: a terminal background whose pane
/// surface has been overridden should follow the override, not the base colour
/// the override was correcting.
pub fn resolve(key: Key, scheme: Scheme, changes: *const Changes) Rgb {
if (changes.overrides.get(key)) |color| return color;
if (key.inherits()) |from| return resolve(from, scheme, changes);
if (changes.tint) |from| return tint.derive(key, scheme, from);
return key.default(scheme);
}
/// Whether anything in this scheme has been changed at all. What the editor's
/// "reset everything" button is greyed out by.
pub fn anySet(overrides: *const Overrides) bool {
for (std.enums.values(Key)) |key| {
if (overrides.get(key) != null) return true;
}
return false;
}
/// Emit the palette as named CSS colours, for `style.css` to be written
/// against.
///
@@ -385,7 +435,7 @@ pub fn anySet(overrides: *const Overrides) bool {
/// Cairo instead, through `theme.zig`.
pub fn writeCss(
scheme: Scheme,
overrides: *const Overrides,
changes: *const Changes,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
for (std.enums.values(Key)) |key| {
@@ -393,7 +443,7 @@ pub fn writeCss(
var buf: [7:0]u8 = undefined;
try writer.print("@define-color {s} {s};\n", .{
name,
resolve(key, scheme, overrides).hex(&buf),
resolve(key, scheme, changes).hex(&buf),
});
}
}
@@ -585,35 +635,69 @@ test "ansi keys map to palette indices in order" {
}
test "an unset colour resolves to its default" {
try std.testing.expectEqual(Rgb.parse("#070c15").?, resolve(.bg, .dark, &no_overrides));
try std.testing.expectEqual(Rgb.parse("#d9e1ee").?, resolve(.bg, .light, &no_overrides));
try std.testing.expectEqual(Rgb.parse("#070c15").?, resolve(.bg, .dark, &unchanged));
try std.testing.expectEqual(Rgb.parse("#d9e1ee").?, resolve(.bg, .light, &unchanged));
}
test "the terminal follows the surface it is drawn on" {
// Unset, the terminal's background is the pane surface...
try std.testing.expectEqual(
resolve(.surface, .dark, &no_overrides),
resolve(.term_bg, .dark, &no_overrides),
resolve(.surface, .dark, &unchanged),
resolve(.term_bg, .dark, &unchanged),
);
// ...including when the surface itself has been changed...
var overrides = no_overrides;
overrides.set(.surface, Rgb.parse("#123456").?);
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.term_bg, .dark, &overrides));
var changes: Changes = .{};
changes.overrides.set(.surface, Rgb.parse("#123456").?);
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.term_bg, .dark, &changes));
// ...and not once it has been given a colour of its own.
overrides.set(.term_bg, Rgb.parse("#abcdef").?);
try std.testing.expectEqual(Rgb.parse("#abcdef").?, resolve(.term_bg, .dark, &overrides));
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.surface, .dark, &overrides));
changes.overrides.set(.term_bg, Rgb.parse("#abcdef").?);
try std.testing.expectEqual(Rgb.parse("#abcdef").?, resolve(.term_bg, .dark, &changes));
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.surface, .dark, &changes));
}
test "a base colour stands in for the defaults, and an override for the base" {
var changes: Changes = .{ .tint = .{ .base = Rgb.parse("#d2691e").? } };
// Nothing has been set by hand, so every colour is the generated one...
try std.testing.expect(!resolve(.bg, .dark, &changes).eql(Key.bg.default(.dark)));
try std.testing.expectEqual(
Rgb.parse("#d2691e").?,
resolve(.accent, .dark, &changes),
);
// ...the terminal still follows the pane surface it is drawn on...
try std.testing.expectEqual(
resolve(.surface, .dark, &changes),
resolve(.term_bg, .dark, &changes),
);
// ...and a colour set by hand outranks the base rather than the other way
// round, which is what lets the two be used together.
changes.overrides.set(.accent, Rgb.parse("#00ff00").?);
try std.testing.expectEqual(Rgb.parse("#00ff00").?, resolve(.accent, .dark, &changes));
try std.testing.expect(!resolve(.bg, .dark, &changes).eql(Key.bg.default(.dark)));
}
test "anySet notices a base with nothing else changed" {
try std.testing.expect(!unchanged.anySet());
const based: Changes = .{ .tint = .{ .base = Rgb.parse("#d2691e").? } };
try std.testing.expect(based.anySet());
var edited: Changes = .{};
edited.overrides.set(.accent, Rgb.parse("#ff8800").?);
try std.testing.expect(edited.anySet());
}
test "writeCss emits every named colour and no others" {
var buf: [css_size]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var overrides = no_overrides;
overrides.set(.accent, Rgb.parse("#ff8800").?);
try writeCss(.dark, &overrides, &writer);
var changes: Changes = .{};
changes.overrides.set(.accent, Rgb.parse("#ff8800").?);
try writeCss(.dark, &changes, &writer);
const css = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, css, "@define-color pp_bg #070c15;\n") != null);
+32
View File
@@ -641,6 +641,38 @@ dnd.playpen-tab-drag {
border-color: @pp_border_strong;
}
/* The contrast slider, for the same reason as the swatch above it: Adwaita
paints a scale in the *desktop's* accent colour, which on a page whose whole
subject is the colour this window is painted from would be the one thing on
screen ignoring it. Left stock, dragging it towards a warm theme leaves a
blue bar sitting in the middle of the result. */
.playpen-contrast trough {
background-color: @pp_surface_raised_active;
}
.playpen-contrast slider {
background-color: @pp_accent;
border: 1px solid @pp_accent_strong;
box-shadow: none;
}
.playpen-contrast:disabled slider {
background-color: @pp_border_strong;
border-color: @pp_border_strong;
}
/* The tick at the neutral position, and the two words at the ends. Faint on
purpose: they label the ends of a scale whose real readout is the window
behind the dialog. */
.playpen-contrast marks indicator {
background-color: @pp_border_strong;
}
.playpen-contrast marks label {
font-size: 0.8em;
color: @pp_text_faint;
}
/* -------------------------------------------------------------------------
The startup list
+8 -8
View File
@@ -49,19 +49,19 @@ const Resolved = struct {
/// built them; only the 16 named ones are ours to set.
ansi: vt.color.Palette,
fn init(for_scheme: Scheme, overrides: *const palette.Overrides) Resolved {
fn init(for_scheme: Scheme, changes: *const palette.Changes) Resolved {
var self: Resolved = .{
.bg = palette.resolve(.term_bg, for_scheme, overrides),
.fg = palette.resolve(.term_fg, for_scheme, overrides),
.cursor = palette.resolve(.term_cursor, for_scheme, overrides),
.selection_bg = palette.resolve(.term_selection_bg, for_scheme, overrides),
.selection_fg = palette.resolve(.term_selection_fg, for_scheme, overrides),
.bg = palette.resolve(.term_bg, for_scheme, changes),
.fg = palette.resolve(.term_fg, for_scheme, changes),
.cursor = palette.resolve(.term_cursor, for_scheme, changes),
.selection_bg = palette.resolve(.term_selection_bg, for_scheme, changes),
.selection_fg = palette.resolve(.term_selection_fg, for_scheme, changes),
.ansi = vt.color.default,
};
for (std.enums.values(palette.Key)) |key| {
const i = key.ansiIndex() orelse continue;
self.ansi[i] = palette.resolve(key, for_scheme, overrides).toVt();
self.ansi[i] = palette.resolve(key, for_scheme, changes).toVt();
}
return self;
@@ -72,7 +72,7 @@ var scheme: Scheme = .dark;
/// Resolved at compile time for the scheme the app opens in, so that the first
/// frame has a palette even though nothing has called `setScheme` yet.
var current: Resolved = .init(.dark, &palette.no_overrides);
var current: Resolved = .init(.dark, &palette.unchanged);
/// Switch the palette the terminal renderer draws with, and re-resolve it
/// against whatever the user has changed. Does not repaint anything by itself;
+538
View File
@@ -0,0 +1,538 @@
//! One colour in, a whole palette out.
//!
//! Forty swatches is a fair editor and a poor starting point. Almost nobody
//! wants to choose a window backdrop, a sidebar, a pane surface, a pane header
//! and two border weights — they want the app to be *green*, and for the six
//! greys behind the green to be the six greys that go with it. That is what this
//! module is: a base colour, and a rule for everything else.
//!
//! It sits underneath the per-colour overrides rather than replacing them (see
//! `palette.resolve`). Picking a base changes what every unedited colour
//! resolves to; a colour that has been set by hand still wins. So the two ways
//! of theming compose in the order you would want them to — build the thing out
//! of one colour, then go and fix the one swatch you don't like — and neither
//! destroys the other's work.
//!
//! **How the rule works.** Every colour is one of three kinds:
//!
//! * Most of them are *tinted*: they take the base's hue, a fraction of its
//! colourfulness, and a fixed lightness. This is the structure of the theme —
//! the surfaces stacked from the backdrop up to the pane header, the three
//! weights of text on them. A green base makes them faintly green, a grey
//! base makes them grey, and their spacing never changes, because that
//! spacing is what tells a pane header from a pane.
//! * Three of them are the *accent*: the base colour itself, and a lighter and a
//! more muted version of it. Picking `#3a76f0` gets you `#3a76f0` as the
//! accent, which is the only behaviour that makes the colour picker feel like
//! it did what you asked.
//! * The rest are *named* — red, green, "failed", "waiting on you". A red that
//! followed the base hue would not be red. These keep their own hue and take
//! only their intensity from the base, so a muted base gets muted status dots
//! rather than three neon ones next to a dusty accent.
//!
//! **Contrast** stretches the lightness ramp about its own ends rather than
//! scaling it about a midpoint. The difference matters in the light scheme,
//! where the surfaces sit hard against white: scaling about a midpoint pushes
//! them all through the ceiling and the pane, the pane header and the sidebar
//! come out the same colour. Anchoring at the ends instead means the ramp can
//! only ever spread apart or draw together, which is the thing the setting
//! claims to do.
//!
//! **The numbers below are the palette Playpen ships**, read back out of it in
//! OKLCh. That is deliberate and worth keeping: base the dark scheme on the
//! accent it already uses and you get the shipped dark scheme back, to within a
//! rounding step. The default palette is therefore not a separate design that
//! this has to be checked against — it is one point in the space this generates,
//! and `palette.zig`'s tables and these stops cannot drift apart without a test
//! noticing.
const std = @import("std");
const oklab = @import("oklab.zig");
const palette = @import("palette.zig");
const Key = palette.Key;
const Rgb = palette.Rgb;
const Scheme = palette.Scheme;
const Tint = palette.Tint;
/// The colour every unedited swatch in `scheme` resolves to under `tint`.
///
/// Total for every key that doesn't inherit one — which is all of them that
/// reach here, since `palette.resolve` follows inheritance before it gets this
/// far. The comptime check at the bottom of the file is what makes that safe to
/// assert rather than handle.
pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
const base = oklab.fromRgb(tint.base);
const contrast = std.math.clamp(tint.contrast, -1, 1);
const stop = stops(scheme).get(key).?;
const ramp = lightness_ramp.get(scheme);
const shade: oklab.Lch = switch (stop.role) {
// Structure: the base's hue, a share of its colourfulness, and a fixed
// rung on the ladder. The share is a fraction rather than an absolute
// so that a grey base yields a grey window — there is no floor here
// that would make a deliberately colourless theme come out faintly
// blue.
.tinted => .{
.l = stop.l,
.c = base.c * stop.c * (1 - contrast * chroma_calm),
.h = base.h,
},
// The accent is the colour that was picked, moved only as far as it has
// to be. The lightness clamp is the whole of that: someone who picks a
// near-black as their base means "make it this colour", not "make the
// one thing that has to stand out invisible", and the two lighter and
// darker accents are then offsets from wherever it landed.
.accent => .{
.l = std.math.clamp(base.l, ramp.accent_floor, ramp.accent_ceiling) + stop.l,
.c = base.c * stop.c * (1 + contrast * chroma_lift),
.h = base.h,
},
// A colour that has to stay the colour it is named after keeps its hue
// outright, and takes from the base only how loud to be — measured
// against the accent the shipped palette was tuned around. The floor
// keeps a grey theme's "failed" marker distinguishable from its
// "finished" one; the ceiling keeps a neon base from producing sixteen
// more neons.
.named => .{
.l = stop.l,
.c = stop.c * std.math.clamp(base.c / reference_chroma, 0.4, 1.3) *
(1 + contrast * chroma_lift),
.h = stop.h,
},
};
return oklab.toRgb(.{
.l = stretch(shade.l, contrast, ramp, stop.role.spread()),
.c = @max(shade.c, 0),
.h = shade.h,
});
}
// -------------------------------------------------------------------------
// Contrast
/// Move a lightness away from — or towards — the ends of its scheme's ramp.
///
/// The ramp's own ends are pushed out first, each towards the nearer end of the
/// scale and by the same *share of the room it has left*, and everything in
/// between is carried along by the affine map that takes the old ends to the new
/// ones. Two things fall out of that shape and both are the point:
///
/// * Nothing can be pushed past black or white, so nothing clips. A ramp that
/// clipped would not merely stop responding at the top — it would collapse the
/// colours nearest the ceiling into each other, which in the light scheme is
/// the pane, the pane header and the sidebar becoming one flat white.
/// * Every gap in the ramp scales by the same factor. Turning contrast up
/// separates the pane from the backdrop and the text from the pane by the same
/// proportion, rather than doing something dramatic at one end of the ladder
/// and nothing at the other.
///
/// `spread` is how much of that a given colour takes. The structural colours
/// take all of it; an accent or a red takes about half, because past a point
/// contrast stops making them starker and starts making them not red.
fn stretch(l: f32, contrast: f32, ramp: Ramp, spread: f32) f32 {
const lo = ramp.lo - contrast * contrast_reach * ramp.lo;
const hi = ramp.hi + contrast * contrast_reach * (1 - ramp.hi);
const moved = lo + (l - ramp.lo) * (hi - lo) / (ramp.hi - ramp.lo);
return l + spread * (moved - l);
}
/// How much of the room left above and below the ramp a full turn of the
/// contrast dial takes. Half: enough that the ends visibly move, and short of
/// the point where the dark scheme's backdrop becomes a black hole and its text
/// becomes bare white.
const contrast_reach = 0.5;
/// Contrast drains a little colour out of the structural surfaces as it goes up
/// — a starker theme is a cleaner one — and puts a little back into the colours
/// whose job is to be seen.
const chroma_calm = 0.20;
const chroma_lift = 0.12;
/// The chroma of the accent both shipped palettes are built around, which is
/// what a `named` colour's intensity is measured against. A base this colourful
/// leaves the status and terminal colours exactly as the tables below name them.
const reference_chroma = 0.1944;
/// The two ends of a scheme's lightness ladder, and the band an accent has to
/// land in to be legible against it.
const Ramp = struct {
/// The lightness of the colour furthest into the background — the window
/// backdrop in the dark scheme, the body text in the light one.
lo: f32,
/// And the furthest into the foreground: the body text, or the white a pane
/// is drawn on.
hi: f32,
accent_floor: f32,
accent_ceiling: f32,
};
const lightness_ramp: std.enums.EnumArray(Scheme, Ramp) = .init(.{
.dark = .{ .lo = 0.1538, .hi = 0.9223, .accent_floor = 0.45, .accent_ceiling = 0.80 },
.light = .{ .lo = 0.2382, .hi = 1.0000, .accent_floor = 0.35, .accent_ceiling = 0.70 },
});
// -------------------------------------------------------------------------
// The stops
const Role = enum {
tinted,
accent,
named,
/// How much of the contrast stretch this kind of colour takes. See
/// `stretch`.
fn spread(self: Role) f32 {
return switch (self) {
.tinted => 1.0,
.accent => 0.5,
.named => 0.45,
};
}
};
/// One colour's recipe in one scheme. What the three numbers mean depends on the
/// role, which is why they are named for their role rather than for OKLCh.
const Stop = struct {
role: Role,
/// `tinted` and `named`: the lightness outright. `accent`: an offset from
/// the base colour's own.
l: f32,
/// `tinted` and `accent`: a multiple of the base colour's chroma. `named`:
/// a chroma outright, scaled by how colourful the base is.
c: f32,
/// `named` only: the hue that makes it the colour it is named after.
h: f32 = 0,
};
fn tinted(l: f32, c: f32) Stop {
return .{ .role = .tinted, .l = l, .c = c };
}
/// `dl` is measured from the base colour's lightness, so that the three accents
/// stay the same distance apart wherever the base sits.
fn accent(dl: f32, c: f32) Stop {
return .{ .role = .accent, .l = dl, .c = c };
}
fn named(l: f32, c: f32, h: f32) Stop {
return .{ .role = .named, .l = l, .c = c, .h = h };
}
const Stops = std.enums.EnumArray(Key, ?Stop);
/// Named because it has to be typed as an *inner* null: an outer one would mean
/// "this table has no stop for anything". A key absent from both tables is one
/// that inherits its colour from another and never reaches this module.
const inherited: ?Stop = null;
fn stops(scheme: Scheme) *const Stops {
return switch (scheme) {
.dark => &dark_stops,
.light => &light_stops,
};
}
/// The dark scheme, read out of `palette.dark` in OKLCh.
///
/// The surface chromas climb from 0.11 to 0.26 of the base's as the surfaces
/// rise, which is the part that is easy to get wrong by hand: a stack of
/// surfaces that all carry the same amount of tint reads as flat no matter how
/// far apart their lightnesses are.
const dark_stops: Stops = .initDefault(inherited, .{
.bg = tinted(0.1538, 0.110),
.sidebar = tinted(0.1861, 0.151),
.surface = tinted(0.1999, 0.158),
.surface_raised = tinted(0.2371, 0.191),
.surface_raised_active = tinted(0.2729, 0.262),
.border = tinted(0.2864, 0.214),
.border_strong = tinted(0.3525, 0.256),
.text = tinted(0.9223, 0.110),
.text_dim = tinted(0.7357, 0.198),
.text_faint = tinted(0.5802, 0.213),
.row_hover = tinted(0.2496, 0.189),
.row_selected = tinted(0.2920, 0.261),
.accent = accent(0.0000, 1.000),
.accent_strong = accent(0.0742, 0.797),
.accent_muted = accent(-0.1450, 0.610),
.ok = named(0.7624, 0.1544, 159.4),
.warn = named(0.8142, 0.1403, 81.3),
.err = named(0.7043, 0.1588, 17.3),
// On dark, the cursor goes to the brighter accent; on light it goes down to
// the mid one, for the reason `palette.Key.hint` gives.
.term_cursor = accent(0.0742, 0.797),
// The four neutral ANSI colours are tinted rather than named: "black" and
// "white" here are the ends of the terminal's own greyscale, and a terminal
// whose greys are a different grey from the window around it looks like a
// pane that failed to load.
.ansi_black = tinted(0.2380, 0.190),
.ansi_red = named(0.6308, 0.150, 21.4),
.ansi_green = named(0.7733, 0.130, 113.4),
.ansi_yellow = named(0.8462, 0.125, 82.9),
.ansi_blue = named(0.6975, 0.095, 244.3),
.ansi_magenta = named(0.7066, 0.110, 318.3),
.ansi_cyan = named(0.7631, 0.085, 185.7),
.ansi_white = tinted(0.8299, 0.110),
.ansi_bright_black = tinted(0.5103, 0.210),
.ansi_bright_red = named(0.6076, 0.170, 21.6),
.ansi_bright_green = named(0.8017, 0.152, 116.4),
.ansi_bright_yellow = named(0.8306, 0.145, 93.7),
.ansi_bright_blue = named(0.7136, 0.110, 253.1),
.ansi_bright_magenta = named(0.7398, 0.125, 314.6),
.ansi_bright_cyan = named(0.7521, 0.100, 180.6),
.ansi_bright_white = tinted(0.9370, 0.110),
});
/// The light scheme, read out of `palette.light` the same way — and not an
/// inversion of the table above, for the reasons that table's own comment gives.
/// The pane surface is pure white and so carries no tint at all; everything else
/// steps *down* from it, while in the dark scheme everything steps up.
const light_stops: Stops = .initDefault(inherited, .{
.bg = tinted(0.9075, 0.095),
.sidebar = tinted(0.9571, 0.050),
.surface = tinted(1.0000, 0.000),
.surface_raised = tinted(0.9718, 0.044),
.surface_raised_active = tinted(0.9433, 0.117),
.border = tinted(0.8780, 0.124),
.border_strong = tinted(0.7935, 0.180),
.text = tinted(0.2382, 0.154),
.text_dim = tinted(0.4705, 0.205),
.text_faint = tinted(0.6183, 0.185),
.row_hover = tinted(0.9315, 0.089),
.row_selected = tinted(0.9072, 0.202),
.accent = accent(0.0000, 1.000),
.accent_strong = accent(-0.1040, 0.812),
.accent_muted = accent(0.2529, 0.377),
.ok = named(0.5329, 0.1101, 163.1),
.warn = named(0.5078, 0.1080, 73.3),
.err = named(0.5530, 0.1708, 19.9),
.term_cursor = accent(0.0000, 1.000),
// `white` and `bright white` are the foreground half of "white text" and go
// dark here, which is the trade every light terminal theme makes; see the
// note on `palette.light_ansi`.
.ansi_black = tinted(0.2582, 0.154),
.ansi_red = named(0.5290, 0.192, 28.5),
.ansi_green = named(0.5244, 0.140, 148.0),
.ansi_yellow = named(0.5221, 0.108, 79.7),
.ansi_blue = named(0.4613, 0.168, 260.8),
.ansi_magenta = named(0.5094, 0.191, 319.9),
.ansi_cyan = named(0.5007, 0.120, 216.8),
.ansi_white = tinted(0.4705, 0.205),
.ansi_bright_black = tinted(0.5802, 0.185),
.ansi_bright_red = named(0.5827, 0.194, 28.4),
.ansi_bright_green = named(0.6124, 0.158, 150.0),
.ansi_bright_yellow = named(0.5972, 0.125, 77.5),
.ansi_bright_blue = named(0.5653, 0.207, 262.4),
.ansi_bright_magenta = named(0.5805, 0.222, 320.3),
.ansi_bright_cyan = named(0.5828, 0.140, 218.1),
.ansi_bright_white = tinted(0.2382, 0.154),
});
// A key has a stop in both tables or in neither, and which it is has to be the
// same answer `palette.Key.inherits` gives — otherwise a generated palette
// either has a hole in it that `derive` walks into, or quietly stops honouring
// an inheritance the rest of the app is written against.
comptime {
for (std.enums.values(Key)) |key| {
const wanted = key.inherits() == null;
if ((dark_stops.get(key) != null) != wanted)
@compileError("dark stop disagrees with inheritance for " ++ @tagName(key));
if ((light_stops.get(key) != null) != wanted)
@compileError("light stop disagrees with inheritance for " ++ @tagName(key));
}
}
// -------------------------------------------------------------------------
const testing = std.testing;
/// The largest difference in any channel, which is the useful measure here: a
/// generated colour is right if you cannot tell it from the one it is standing
/// in for, and a couple of steps in one channel is well inside that.
fn drift(a: Rgb, b: Rgb) u16 {
return @max(
@abs(@as(i16, a.r) - @as(i16, b.r)),
@max(
@abs(@as(i16, a.g) - @as(i16, b.g)),
@abs(@as(i16, a.b) - @as(i16, b.b)),
),
);
}
test "the accent regenerates the palette it was taken from" {
// The claim the module header makes: the shipped palettes are points in the
// space this generates, not a separate design. Retune one without retuning
// the other and this is what says so.
for (std.enums.values(Scheme)) |scheme| {
const tint: Tint = .{ .base = Key.accent.default(scheme) };
for (std.enums.values(Key)) |key| {
if (key.inherits() != null) continue;
const generated = derive(key, scheme, tint);
const shipped = key.default(scheme);
// Wider for the ANSI colours: those stops are a deliberate retune
// rather than a transcription — libghostty-vt's dark sixteen are
// washed out beside a full-strength accent, and the shipped light
// cyan is duller than the rest of its row.
const allowed: u16 = if (key.ansiIndex() != null) 40 else 20;
if (drift(generated, shipped) <= allowed) continue;
var want: [7:0]u8 = undefined;
var got: [7:0]u8 = undefined;
std.debug.print("{s} {s}: generated {s}, ships {s}\n", .{
@tagName(scheme), @tagName(key), generated.hex(&got), shipped.hex(&want),
});
return error.GeneratedPaletteDrifted;
}
}
}
test "the accent is the colour that was picked" {
// Anything else makes the picker feel broken. The only licence taken is the
// lightness clamp, and these two sit well inside it.
for ([_][]const u8{ "#3a76f0", "#d2691e" }) |text| {
const base = Rgb.parse(text).?;
for (std.enums.values(Scheme)) |scheme| {
try testing.expect(drift(derive(.accent, scheme, .{ .base = base }), base) <= 2);
}
}
}
test "a base too dark to see is lifted into view" {
const base = Rgb.parse("#050a12").?;
const got = oklab.fromRgb(derive(.accent, .dark, .{ .base = base }));
try testing.expect(got.l >= 0.44);
try testing.expectApproxEqAbs(oklab.fromRgb(base).h, got.h, 2.0);
}
test "the surfaces stay in order, whatever the base and the contrast" {
// The one property the whole theme rests on: a pane has to be visible
// against the backdrop and a pane header against the pane. Lightness only —
// the hue is the base's for all three, so lightness is the entire
// separation.
const rising = [_]Key{ .bg, .sidebar, .surface, .surface_raised, .surface_raised_active };
for ([_][]const u8{ "#3a76f0", "#d2691e", "#808080", "#000000", "#ffffff", "#00ff40" }) |text| {
const base = Rgb.parse(text).?;
for ([_]f32{ -1, -0.5, 0, 0.5, 1 }) |contrast| {
for (std.enums.values(Scheme)) |scheme| {
const tint: Tint = .{ .base = base, .contrast = contrast };
var previous = oklab.fromRgb(derive(rising[0], scheme, tint)).l;
for (rising[1..]) |key| {
const l = oklab.fromRgb(derive(key, scheme, tint)).l;
// Light steps *down* from its pane surface for the two
// raised colours, so it is the size of each step that has to
// hold rather than the direction of all of them.
//
// The floor is low because the tightest step in the shipped
// dark palette — the sidebar against the pane beside it — is
// itself only 0.014, those two being separated as much by
// the gutter between them as by their colours. Softening the
// contrast draws that to about 0.012, which is the number
// this has to sit under. What it is really watching for is a
// step going to nothing.
if (@abs(l - previous) < 0.008) {
std.debug.print("{s} {s} c={d}: {s} is indistinguishable from what is under it\n", .{
text, @tagName(scheme), contrast, @tagName(key),
});
return error.SurfacesCollapsed;
}
previous = l;
}
}
}
}
}
test "text stays legible against the surface it is on" {
for ([_][]const u8{ "#3a76f0", "#d2691e", "#808080", "#00ff40" }) |text| {
const base = Rgb.parse(text).?;
for ([_]f32{ -1, 0, 1 }) |contrast| {
for (std.enums.values(Scheme)) |scheme| {
const tint: Tint = .{ .base = base, .contrast = contrast };
const surface = oklab.fromRgb(derive(.surface, scheme, tint)).l;
// Body text and the dim text under it. The faint weight is
// deliberately near the edge of readable — it labels things you
// aren't reading — so it is not held to this.
for ([_]Key{ .text, .text_dim }) |key| {
const on_top = oklab.fromRgb(derive(key, scheme, tint)).l;
try testing.expect(@abs(on_top - surface) > 0.24);
}
}
}
}
}
test "contrast spreads the ramp without pushing anything off the end" {
const base = Rgb.parse("#3a76f0").?;
for (std.enums.values(Scheme)) |scheme| {
const soft = span(scheme, .{ .base = base, .contrast = -1 });
const level = span(scheme, .{ .base = base, .contrast = 0 });
const stark = span(scheme, .{ .base = base, .contrast = 1 });
try testing.expect(soft < level);
try testing.expect(level < stark);
// And the colours nearest the ceiling stay distinct rather than piling
// up against it, which is what a ramp that scaled about its middle would
// do to the light scheme.
const tint: Tint = .{ .base = base, .contrast = 1 };
try testing.expect(!derive(.surface, scheme, tint).eql(derive(.surface_raised, scheme, tint)));
try testing.expect(!derive(.surface, scheme, tint).eql(derive(.sidebar, scheme, tint)));
}
}
/// How far apart the two ends of the ramp are: the backdrop and the body text.
fn span(scheme: Scheme, tint: Tint) f32 {
return @abs(oklab.fromRgb(derive(.text, scheme, tint)).l -
oklab.fromRgb(derive(.bg, scheme, tint)).l);
}
test "a grey base makes a grey theme, and keeps the status colours apart" {
const tint: Tint = .{ .base = Rgb.parse("#808080").? };
for (std.enums.values(Scheme)) |scheme| {
// No floor sneaking colour back into a theme that asked for none.
for ([_]Key{ .bg, .surface, .text, .accent }) |key| {
try testing.expect(oklab.fromRgb(derive(key, scheme, tint)).c < 0.01);
}
// The three states still have to be three states.
const ok = derive(.ok, scheme, tint);
const warn = derive(.warn, scheme, tint);
const err = derive(.err, scheme, tint);
try testing.expect(drift(ok, warn) > 20);
try testing.expect(drift(warn, err) > 20);
try testing.expect(drift(ok, err) > 20);
}
}