Add quick theming tool.
This commit is contained in:
+237
-16
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user