From 071522f67c3175b23bad5242d7344774c238913e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 24 Aug 2026 16:45:21 -0400 Subject: [PATCH] Add quick theming tool. --- src/PaletteEditor.zig | 253 ++++++++++++++++++-- src/Settings.zig | 203 ++++++++++++++-- src/oklab.zig | 260 ++++++++++++++++++++ src/palette.zig | 142 ++++++++--- src/style.css | 32 +++ src/theme.zig | 16 +- src/tint.zig | 538 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1367 insertions(+), 77 deletions(-) create mode 100644 src/oklab.zig create mode 100644 src/tint.zig diff --git a/src/PaletteEditor.zig b/src/PaletteEditor.zig index 98870b5..8ec24e1 100644 --- a/src/PaletteEditor.zig +++ b/src/PaletteEditor.zig @@ -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 diff --git a/src/Settings.zig b/src/Settings.zig index 8d40f45..1fc006b 100644 --- a/src/Settings.zig +++ b/src/Settings.zig @@ -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); diff --git a/src/oklab.zig b/src/oklab.zig new file mode 100644 index 0000000..6cef7f0 --- /dev/null +++ b/src/oklab.zig @@ -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; + } + } +} diff --git a/src/palette.zig b/src/palette.zig index 34863cb..d20883d 100644 --- a/src/palette.zig +++ b/src/palette.zig @@ -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); diff --git a/src/style.css b/src/style.css index 5c114d0..46e33aa 100644 --- a/src/style.css +++ b/src/style.css @@ -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 diff --git a/src/theme.zig b/src/theme.zig index 3361132..901e93e 100644 --- a/src/theme.zig +++ b/src/theme.zig @@ -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; diff --git a/src/tint.zig b/src/tint.zig new file mode 100644 index 0000000..8d583bd --- /dev/null +++ b/src/tint.zig @@ -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); + } +}