From 695417a60168a83c59364ef9dd070ec9768716ce Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 13 Aug 2026 15:55:23 -0400 Subject: [PATCH] Add theme customizer. --- README.md | 83 +++++- build.zig | 30 ++ src/PaletteEditor.zig | 498 ++++++++++++++++++++++++++++++++ src/Settings.zig | 232 ++++++++++++++- src/SettingsDialog.zig | 28 +- src/Terminal.zig | 16 +- src/appearance.zig | 62 +++- src/palette-dark.css | 47 --- src/palette-light.css | 36 --- src/palette.zig | 633 +++++++++++++++++++++++++++++++++++++++++ src/style.css | 56 +++- src/theme.zig | 184 ++++-------- 12 files changed, 1661 insertions(+), 244 deletions(-) create mode 100644 src/PaletteEditor.zig delete mode 100644 src/palette-dark.css delete mode 100644 src/palette-light.css create mode 100644 src/palette.zig diff --git a/README.md b/README.md index 4906762..5ecde7f 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,11 @@ Session.zig libghostty-vt Terminal + parser, fed by the PTY Pty.zig openpt/fork/exec, controlling terminal setup script.zig runs a command for `$(...)` in a layout's directory key.zig GDK keyval -> libghostty-vt key mapping -theme.zig colors libghostty-vt has no opinion about, per scheme -Settings.zig preferences: theme and startup tabs, JSON on disk +palette.zig every colour by name, its default per scheme, and the CSS for it +theme.zig the palette resolved for the terminal renderer, which reads numbers +Settings.zig preferences: theme, palette overrides, startup tabs, JSON on disk SettingsDialog.zig the settings page, including the startup list editor +PaletteEditor.zig the colour editor inside it: a swatch per palette entry TabSettingsDialog.zig one tab's own settings, and the emoji picker emoji.zig generated: every emoji the picker offers, and the search over them ``` @@ -392,6 +394,11 @@ in principle, but a terminal grid is small. - **Light and dark schemes**, following the desktop by default and pinnable from the settings page, applying to open tabs immediately — terminal palette included. See [Theme](#theme) +- **Every colour in the palette editable**, per scheme, from **Settings → + Appearance → Customise**: the surfaces, the text on them, the accents, the + status colours, and the terminal's own background, cursor, selection and 16 + ANSI colours. Each change repaints as you make it. See + [Customising the palette](#customising-the-palette) ### Shortcuts @@ -669,6 +676,9 @@ light and a dark scheme. `Ctrl+,` or the gear at the foot of the sidebar opens **Settings**, whose Appearance section holds the choice: light, dark, or **system**, which is the default and follows the desktop. +Every colour it resolves to can be changed — see +[Customising the palette](#customising-the-palette). + The preference lives in `~/.config/playpen/settings.json` (or `$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the [startup tabs](#startup-tabs): @@ -677,6 +687,7 @@ The preference lives in `~/.config/playpen/settings.json` (or { "version": 1, "theme": "system", + "colors": {}, "startup": [] } ``` @@ -691,15 +702,18 @@ Three separate colour systems have to agree for that to be true, which is what dialog chrome. It is told to force a scheme, or left on `default` to follow the desktop. - **`style.css`** colours everything the app draws itself. It is written - entirely against named colours, with one palette file per scheme - (`palette-dark.css`, `palette-light.css`); the matching palette is prepended - and the pair loaded as a single `GtkCssProvider`. No rule in `style.css` may - hardcode a colour — a literal hex is a rule that looks right in whichever - scheme you happened to be testing in. + entirely against named colours; `palette.zig` writes the `@define-color` + block that defines them for the scheme in force, and the two are loaded as a + single `GtkCssProvider`. No rule in `style.css` may hardcode a colour — a + literal hex is a rule that looks right in whichever scheme you happened to be + testing in, and one the colour editor cannot reach. - **`theme.zig`** holds what Cairo draws the terminal grid from: the default background, foreground and cursor, plus the 16 ANSI colours. The style tree is never consulted there, so a CSS reload alone would leave every terminal - painted in the scheme it started in. + painted in the scheme it started in. It resolves the same table `style.css` + is fed from, and caches the result — the renderer asks for the background + once a frame and for the palette once a cell, and neither should be going + through the settings for an answer. The ANSI palette is the part that is easy to skip and shouldn't be. The standard xterm yellow is `#cdcd00`, which on a white background is close to @@ -718,6 +732,59 @@ its `dark` property rather than the stored preference, and the preference only decides what the style manager is told. A desktop that switches at sunset takes this app with it, with no extra machinery and no second source of truth. +### Customising the palette + +**Settings → Appearance → Customise** opens the palette itself: a swatch for +every colour named above, in sections — surfaces, text, tab rows, accent, +status, then the terminal's own background, foreground, cursor and selection, +and the 16 ANSI colours as a strip. Beside each row is the hex it currently +resolves to, so a palette can be read off as text rather than only picked at, +and a button that puts that one colour back. Each section heading has the same +button for the section, and the foot of the list has one for the whole scheme. + +Three things about how it behaves, all following from the same decision — that +the editor edits **the palette you can see**: + +- **A change applies as you make it.** Every colour in the list paints something + behind the dialog, so the swatch is its own preview; there is no OK button + because there is nothing pending to confirm. +- **It edits the scheme in force.** Which is why the way to edit the other one is + the theme picker directly above: switch to light, and the section switches with + it. Both palettes are kept, so an afternoon spent pinned to light doesn't cost + you the dark palette you built. +- **The terminal follows the window.** Left alone, the terminal's background *is* + the pane surface, its foreground *is* the body text, and its selection *is* the + muted accent — so recolouring the app recolours the terminal drawn inside it, + and the frame around a terminal keeps matching its contents. Setting one of + those explicitly breaks the link for that colour and only that colour. + +Only what has been changed is stored, per scheme, under `colors` in +`settings.json`: + +```json +{ + "version": 1, + "theme": "dark", + "colors": { + "dark": { "accent": "#ff8800", "sidebar": "#3a0d0d" } + } +} +``` + +That is a deliberate choice over writing whole palettes out. It keeps the file +short and hand-editable, it is what lets a row know whether it has been changed, +and it means the shipped defaults can be retuned in a later version and still +reach everyone who never touched them — rather than only the people who had +never opened the editor. A name this build doesn't know, or a value that isn't +`#rrggbb`, costs that one colour and is logged; the rest of the file is read +normally. + +`palette.zig` is where the names, the defaults and the grouping live, which is +why there is no longer a `palette-dark.css`. An editor needs to know what the +colours are called, what they started as, and which ones belong together, and +none of that can be read back out of a stylesheet without parsing it — so the +table answers all of it and the CSS is generated from the table. + ## Not implemented This is a proof of concept, and the following are deliberately absent: diff --git a/build.zig b/build.zig index 7e36263..ad8fb45 100644 --- a/build.zig +++ b/build.zig @@ -85,6 +85,36 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run the tests"); test_step.dependOn(&b.addRunArtifact(tests).step); + // The palette is the third root: a table of colours, the rules for + // resolving one, and the CSS it is written out as. It reaches libghostty-vt + // for the terminal's own default palette and nothing else, so it tests + // without a display in the same way the two above do. + const palette_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/palette.zig"), + .target = target, + .optimize = optimize, + }), + }); + palette_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); + test_step.dependOn(&b.addRunArtifact(palette_tests).step); + + // And a root for the settings file itself. `Settings.zig` reaches GLib for + // where the file lives, but reading and writing its *contents* is pure + // enough to test — which is what these cover, since the palette overrides + // are a nested object whose shape one launch has to agree with the next on. + const settings_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/Settings.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + settings_tests.root_module.addImport("glib", gobject.module("glib2")); + settings_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); + test_step.dependOn(&b.addRunArtifact(settings_tests).step); + // A second root for the same reason, one step further out: `emoji.zig` is a // table and a search over it, and it imports nothing at all. It cannot hang // off the root above because a test binary has exactly one root, and diff --git a/src/PaletteEditor.zig b/src/PaletteEditor.zig new file mode 100644 index 0000000..98870b5 --- /dev/null +++ b/src/PaletteEditor.zig @@ -0,0 +1,498 @@ +//! The colour editor: one swatch per name in the palette, for the scheme that +//! is on screen. +//! +//! It edits **the palette you can see**, and that is the whole design. Every +//! colour here paints something behind this window, so a change is its own +//! preview — pick a sidebar colour and the sidebar is that colour before the +//! dialog has closed. Editing the *other* scheme's palette from here would mean +//! a page full of swatches whose effect you have to take on faith, so the +//! answer to "I want to change the light one" is the theme picker above: switch +//! to light, and this section switches with it. +//! +//! Overrides are stored per colour rather than as a whole palette, which is what +//! lets a row say whether it has been changed and put itself back, and what lets +//! the defaults be retuned in a later version for everyone who never touched +//! them. The terminal's colours go one step further: left alone they *follow* +//! the surface and text colours above them (see `palette.Key.inherits`), so +//! recolouring the app recolours the terminal drawn on it without asking anyone +//! 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. + +const std = @import("std"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const Settings = @import("Settings.zig"); +const appearance = @import("appearance.zig"); +const palette = @import("palette.zig"); +const theme = @import("theme.zig"); + +const PaletteEditor = @This(); + +/// How the editor reports a settings file it couldn't write. The settings page +/// owns the label it goes in, since the startup list below reports the same +/// failure the same way. +pub const Report = *const fn (ctx: ?*anyopaque, message: ?[:0]const u8) void; + +pub const Options = struct { + on_report: Report, + ctx: ?*anyopaque = null, +}; + +/// One colour: its swatch, and — for the ones listed as rows rather than packed +/// into the ANSI grid — the hex it currently resolves to and the button that +/// puts it back. +const Row = struct { + editor: *PaletteEditor, + key: palette.Key, + swatch: *gtk.ColorDialogButton, + hex: ?*gtk.Label = null, + revert: ?*gtk.Button = null, +}; + +/// One section heading, kept only so its reset button can be greyed out when +/// the section has nothing to reset. +const GroupHeader = struct { + editor: *PaletteEditor, + group: palette.Group, + reset: *gtk.Button, +}; + +const group_count = std.enums.values(palette.Group).len; + +alloc: std.mem.Allocator, + +/// The whole section: the disclosure row, and the swatches under it. +root: *gtk.Box, + +/// Everything the toggle shows and hides. +body: *gtk.Box, + +/// Says which scheme is being edited, and is rewritten when that changes. +scheme_hint: *gtk.Label, + +/// Puts the whole scheme back to its defaults. +reset: *gtk.Button, + +/// Indexed by `@intFromEnum(key)`, so a row is found without searching. Inline +/// in the struct rather than allocated: there is exactly one row per colour, the +/// count is known at compile time, and the addresses have to be stable because +/// every swatch's callback holds one. +rows: [palette.count]Row, +groups: [group_count]GroupHeader, + +/// Set while the rows are being written from the settings, so that the +/// `notify::rgba` each `setRgba` provokes doesn't come back in as an edit. +updating: bool = false, + +on_report: Report, +ctx: ?*anyopaque, + +pub fn create(alloc: std.mem.Allocator, opts: Options) !*PaletteEditor { + const self = try alloc.create(PaletteEditor); + errdefer alloc.destroy(self); + + self.* = .{ + .alloc = alloc, + .root = gtk.Box.new(.vertical, 10), + .body = gtk.Box.new(.vertical, 14), + .scheme_hint = gtk.Label.new(null), + .reset = gtk.Button.newWithLabel("Reset every colour"), + .rows = undefined, + .groups = undefined, + .on_report = opts.on_report, + .ctx = opts.ctx, + }; + + self.build(); + self.reload(); + return self; +} + +/// Free the editor's own memory. The widgets belong to the window that is being +/// torn down around them, so there is nothing to destroy here. +pub fn destroy(self: *PaletteEditor) void { + self.alloc.destroy(self); +} + +pub fn widget(self: *PaletteEditor) *gtk.Widget { + return self.root.as(gtk.Widget); +} + +/// Re-read every swatch from the settings. +/// +/// Called by the settings page when the theme changes, because the scheme being +/// edited changed with it — the same rows now stand for the other palette. +pub fn refresh(self: *PaletteEditor) void { + self.reload(); +} + +// ------------------------------------------------------------------------- +// Building + +fn build(self: *PaletteEditor) void { + self.root.append(self.buildHeader()); + + 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.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", + ); + _ = gtk.Button.signals.clicked.connect(self.reset, *PaletteEditor, &onReset, self, .{}); + self.body.append(self.reset.as(gtk.Widget)); + + // Collapsed until asked for; see the note at the top of the file. + self.body.as(gtk.Widget).setVisible(0); + self.root.append(self.body.as(gtk.Widget)); +} + +/// The disclosure row, in the same shape as the theme row above it: what it is +/// on the left, the control on the right. +fn buildHeader(self: *PaletteEditor) *gtk.Widget { + const row = gtk.Box.new(.horizontal, 12); + + const labels = gtk.Box.new(.vertical, 2); + labels.as(gtk.Widget).setHexpand(1); + labels.as(gtk.Widget).setValign(.center); + + const name = gtk.Label.new("Palette"); + name.setXalign(0); + name.as(gtk.Widget).addCssClass("playpen-dialog-label"); + labels.append(name.as(gtk.Widget)); + + const sub = gtk.Label.new("Every colour the window and the terminal are painted from."); + sub.setXalign(0); + sub.setWrap(1); + sub.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); + labels.append(sub.as(gtk.Widget)); + + row.append(labels.as(gtk.Widget)); + + // Wrapped rather than styled directly: the settings page's button styling + // hangs off the container, so this comes out looking like one of the theme + // buttons beside it rather than like a stock Adwaita button. + const wrap = gtk.Box.new(.horizontal, 0); + wrap.as(gtk.Widget).addCssClass("linked"); + wrap.as(gtk.Widget).addCssClass("playpen-settings-choice"); + wrap.as(gtk.Widget).setValign(.center); + + const toggle = gtk.ToggleButton.newWithLabel("Customise"); + _ = gtk.ToggleButton.signals.toggled.connect( + toggle, + *PaletteEditor, + &onDisclosureToggled, + self, + .{}, + ); + wrap.append(toggle.as(gtk.Widget)); + + row.append(wrap.as(gtk.Widget)); + + return row.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 { + var current: ?palette.Group = null; + var section: *gtk.Box = undefined; + + // The ANSI colours are a grid of bare swatches rather than sixteen labelled + // rows: they are a palette people recognise by position — eight normal, then + // eight bright — and a list of their names says less than the strip does. + var ansi: *gtk.Grid = undefined; + + for (std.enums.values(palette.Key)) |key| { + const group = key.group(); + + if (current == null or current.? != group) { + current = group; + section = gtk.Box.new(.vertical, 6); + section.as(gtk.Widget).addCssClass("playpen-color-group"); + section.append(self.buildGroupHeader(group)); + if (group.hint()) |text| section.append(hint(text)); + + if (group == .ansi) { + ansi = gtk.Grid.new(); + ansi.setRowSpacing(4); + ansi.setColumnSpacing(4); + ansi.as(gtk.Widget).setHalign(.start); + section.append(ansi.as(gtk.Widget)); + } + + self.body.append(section.as(gtk.Widget)); + } + + if (key.ansiIndex()) |i| { + const swatch = self.newSwatch(key, 30, 22); + ansi.attach(swatch.as(gtk.Widget), @intCast(i % 8), @intCast(i / 8), 1, 1); + } else { + section.append(self.buildRow(key)); + } + } +} + +fn buildGroupHeader(self: *PaletteEditor, group: palette.Group) *gtk.Widget { + const row = gtk.Box.new(.horizontal, 6); + + const title = gtk.Label.new(group.label()); + title.setXalign(0); + title.as(gtk.Widget).setHexpand(1); + title.as(gtk.Widget).addCssClass("playpen-color-group-title"); + row.append(title.as(gtk.Widget)); + + const reset = gtk.Button.newFromIconName("edit-undo-symbolic"); + reset.as(gtk.Widget).addCssClass("flat"); + reset.as(gtk.Widget).setTooltipText("Reset the colours in this section"); + + const header = &self.groups[@intFromEnum(group)]; + header.* = .{ .editor = self, .group = group, .reset = reset }; + _ = gtk.Button.signals.clicked.connect(reset, *GroupHeader, &onResetGroup, header, .{}); + + row.append(reset.as(gtk.Widget)); + + return row.as(gtk.Widget); +} + +/// One colour as a row: what it is, what it currently is, and the two controls +/// for changing that. +fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget { + const row = gtk.Box.new(.horizontal, 8); + row.as(gtk.Widget).addCssClass("playpen-color-row"); + + const label = gtk.Label.new(key.label()); + label.setXalign(0); + label.as(gtk.Widget).setHexpand(1); + label.as(gtk.Widget).addCssClass("playpen-dialog-label"); + row.append(label.as(gtk.Widget)); + + // The tooltip carries what the colour is *for*, plus the name it goes by in + // the settings file, since anyone hand-editing that file is looking at this + // list to find out what to call things. + var tip: [256]u8 = undefined; + const tooltip: [:0]const u8 = std.fmt.bufPrintZ(&tip, "{s}{s}{s}", .{ + key.hint() orelse "", + if (key.hint() != null) "\n" else "", + @tagName(key), + }) catch @tagName(key); + row.as(gtk.Widget).setTooltipText(tooltip); + + const hex = gtk.Label.new(null); + hex.as(gtk.Widget).addCssClass("playpen-color-hex"); + row.append(hex.as(gtk.Widget)); + + const swatch = self.newSwatch(key, 52, 24); + row.append(swatch.as(gtk.Widget)); + + const revert = gtk.Button.newFromIconName("edit-undo-symbolic"); + revert.as(gtk.Widget).addCssClass("flat"); + revert.as(gtk.Widget).setTooltipText("Back to the default"); + + const entry = &self.rows[@intFromEnum(key)]; + entry.hex = hex; + entry.revert = revert; + _ = gtk.Button.signals.clicked.connect(revert, *Row, &onRevert, entry, .{}); + + row.append(revert.as(gtk.Widget)); + + return row.as(gtk.Widget); +} + +/// A swatch, and the row record that goes with it. +/// +/// Alpha is off: `style.css` derives every wash it needs from `alpha()` on the +/// colour it belongs to, so a translucent palette entry would be a second way +/// of saying the same thing, and one the terminal renderer could not honour. +fn newSwatch( + self: *PaletteEditor, + key: palette.Key, + width: c_int, + height: c_int, +) *gtk.ColorDialogButton { + const dialog = gtk.ColorDialog.new(); + dialog.setWithAlpha(0); + dialog.setModal(1); + dialog.setTitle(key.label()); + + const swatch = gtk.ColorDialogButton.new(dialog); + swatch.as(gtk.Widget).setValign(.center); + swatch.as(gtk.Widget).setSizeRequest(width, height); + swatch.as(gtk.Widget).setTooltipText(key.label()); + + const entry = &self.rows[@intFromEnum(key)]; + entry.* = .{ .editor = self, .key = key, .swatch = swatch }; + + _ = gobject.Object.signals.notify.connect( + swatch, + *Row, + &onColorPicked, + entry, + .{ .detail = "rgba" }, + ); + + return swatch; +} + +// ------------------------------------------------------------------------- +// Reading the settings back into the rows + +fn reload(self: *PaletteEditor) void { + self.updating = true; + defer self.updating = false; + + const scheme = theme.currentScheme(); + const overrides = Settings.get().colors.of(scheme); + + var buf: [192]u8 = undefined; + const heading: [:0]const u8 = std.fmt.bufPrintZ( + &buf, + "Editing the {s} palette, the one on screen — changes apply as you make them. " ++ + "Switch the theme above to edit the other.", + .{scheme.label()}, + ) catch "Changes apply as you make them."; + self.scheme_hint.setText(heading); + + 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); + + var rgba = toRgba(color); + row.swatch.setRgba(&rgba); + + if (row.hex) |label| { + var hex_buf: [7:0]u8 = undefined; + label.setText(color.hex(&hex_buf)); + } + + // Whether there is anything to revert *to*, which is a different + // question: an inherited colour is not one that has been set. + if (row.revert) |button| { + button.as(gtk.Widget).setSensitive(@intFromBool(overrides.get(row.key) != null)); + } + } + + for (&self.groups) |*header| { + header.reset.as(gtk.Widget).setSensitive(@intFromBool(groupIsSet(header.group, overrides))); + } + + self.reset.as(gtk.Widget).setSensitive(@intFromBool(palette.anySet(overrides))); +} + +fn groupIsSet(group: palette.Group, overrides: *const palette.Overrides) bool { + for (std.enums.values(palette.Key)) |key| { + if (key.group() == group and overrides.get(key) != null) return true; + } + return false; +} + +/// Show the change, then write it down. +/// +/// In that order on purpose: repainting is what the user asked for and cannot +/// fail, while the write can — and a settings file that couldn't be written is +/// worth saying so about without also refusing the colour for this session. +fn applied(self: *PaletteEditor) void { + appearance.refresh(); + self.reload(); + + Settings.get().save() catch { + self.on_report(self.ctx, "Could not write the settings file."); + return; + }; + self.on_report(self.ctx, null); +} + +// ------------------------------------------------------------------------- +// Handlers + +fn onDisclosureToggled(button: *gtk.ToggleButton, self: *PaletteEditor) callconv(.c) void { + self.body.as(gtk.Widget).setVisible(button.getActive()); +} + +fn onColorPicked( + swatch: *gtk.ColorDialogButton, + _: *gobject.ParamSpec, + row: *Row, +) callconv(.c) void { + const self = row.editor; + if (self.updating) return; + + // The scheme is read here rather than remembered from the last reload: a + // desktop that flips to light under a `system` preference while this page is + // open changes which palette an edit belongs to, and writing to the one the + // 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())); + + 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); + self.applied(); +} + +fn onResetGroup(_: *gtk.Button, header: *GroupHeader) callconv(.c) void { + const self = header.editor; + const overrides = Settings.get().colors.of(theme.currentScheme()); + + for (std.enums.values(palette.Key)) |key| { + if (key.group() == header.group) overrides.set(key, null); + } + + self.applied(); +} + +fn onReset(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void { + Settings.get().colors.of(theme.currentScheme()).* = palette.no_overrides; + self.applied(); +} + +// ------------------------------------------------------------------------- +// Small helpers + +fn hint(text: [:0]const u8) *gtk.Widget { + const label = gtk.Label.new(text); + label.setXalign(0); + label.setWrap(1); + label.as(gtk.Widget).addCssClass("playpen-dialog-hint"); + return label.as(gtk.Widget); +} + +fn toRgba(color: palette.Rgb) gdk.RGBA { + return .{ + .f_red = @as(f32, @floatFromInt(color.r)) / 255.0, + .f_green = @as(f32, @floatFromInt(color.g)) / 255.0, + .f_blue = @as(f32, @floatFromInt(color.b)) / 255.0, + .f_alpha = 1.0, + }; +} + +/// GTK works in floats and the palette works in bytes, so a colour that came +/// out of the picker's own HSV wheel or its eyedropper has to be rounded to +/// something a hex triplet can hold. Rounding rather than truncating, so that a +/// colour typed in as hex comes back as the same hex. +fn fromRgba(rgba: *const gdk.RGBA) palette.Rgb { + return .{ + .r = channel(rgba.f_red), + .g = channel(rgba.f_green), + .b = channel(rgba.f_blue), + }; +} + +fn channel(value: f32) u8 { + return @intFromFloat(@round(std.math.clamp(value, 0.0, 1.0) * 255.0)); +} diff --git a/src/Settings.zig b/src/Settings.zig index 9971cdf..8e58892 100644 --- a/src/Settings.zig +++ b/src/Settings.zig @@ -4,12 +4,19 @@ //! something changes. It carries a version from the start, so adding the next //! setting is not also the day we invent a format. //! -//! There are two settings, and they are very different shapes. The colour -//! scheme is one word. 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 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 +//! 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 +//! point, `setStartup` replaces the arena wholesale, so anything colour-shaped +//! living in it would be freed by an unrelated edit to the startup list. //! //! Because saving rewrites the whole file, there is exactly one `Settings` for //! the process, reached through `get`. Two holders each convinced they knew @@ -24,6 +31,8 @@ const std = @import("std"); const glib = @import("glib"); +const palette = @import("palette.zig"); + const Settings = @This(); /// Bumped only if the on-disk shape changes incompatibly. Read but not yet @@ -58,6 +67,23 @@ pub const Theme = enum { } }; +/// The palette colours someone has changed, per scheme. +/// +/// 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, + + pub fn of(self: *Colors, scheme: palette.Scheme) *palette.Overrides { + return switch (scheme) { + .dark => &self.dark, + .light => &self.light, + }; + } +}; + /// One of a layout's parameters, and what a startup tab opens it with. pub const Value = struct { name: []const u8, @@ -95,6 +121,11 @@ arena: std.heap.ArenaAllocator, theme: Theme = .system, +/// Changes to the palette. Empty means every colour is at its default, which is +/// the state anyone who never opens the colour editor stays in — and it is the +/// reason the defaults can be retuned in a later version and still reach them. +colors: Colors = .{}, + /// The tabs to open at launch, in order. Empty means a single plain shell, /// which is what the app did before this setting existed. startup: []const StartupTab = &.{}, @@ -203,9 +234,65 @@ fn parse(self: *Settings, text: []const u8) ParseError!void { } } + if (root.get("colors")) |value| self.parseColors(value); + if (root.get("startup")) |value| try self.parseStartup(value); } +/// Read the palette overrides: `{"dark": {"accent": "#ff8800"}, "light": {…}}`. +/// +/// Nothing here can fail the file. A colour is one word of a palette, and the +/// answer to a word we don't recognise or can't parse is the default for it — +/// which is exactly what an absent key already means, so an unknown name from a +/// newer version and a typo in a hand-edited file cost the same nothing. +fn parseColors(self: *Settings, raw: std.json.Value) void { + const obj = switch (raw) { + .object => |o| o, + else => { + std.log.warn("settings: \"colors\" is not an object; ignoring it", .{}); + return; + }, + }; + + for (std.enums.values(palette.Scheme)) |scheme| { + const entry = obj.get(@tagName(scheme)) orelse continue; + const colors = switch (entry) { + .object => |o| o, + else => { + std.log.warn("settings: colors.{s} is not an object; ignoring it", .{@tagName(scheme)}); + continue; + }, + }; + + const into = self.colors.of(scheme); + + var it = colors.iterator(); + while (it.next()) |kv| { + const key = std.meta.stringToEnum(palette.Key, kv.key_ptr.*) orelse { + std.log.warn("settings: no colour called \"{s}\"; ignoring it", .{kv.key_ptr.*}); + continue; + }; + + const text = switch (kv.value_ptr.*) { + .string => |s| s, + else => { + std.log.warn("settings: colors.{s}.{s} is not a string", .{ + @tagName(scheme), @tagName(key), + }); + continue; + }, + }; + + const color = palette.Rgb.parse(text) orelse { + std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text}); + continue; + }; + + into.set(key, color); + } + } +} + /// Read the startup list. /// /// Entries that make no sense are skipped rather than failing the file. The @@ -367,6 +454,31 @@ fn serialize(self: *Settings) SaveError![]u8 { try json.objectField("theme"); try json.write(@tagName(self.theme)); + // Only the colours that have been changed, and only the schemes that have + // any. The object itself is written even when empty, for the same reason + // the startup list below is: a file you have opened to see what you can put + // in it should name what it accepts. + try json.objectField("colors"); + try json.beginObject(); + for (std.enums.values(palette.Scheme)) |scheme| { + const colors = self.colors.of(scheme); + + var started = false; + for (std.enums.values(palette.Key)) |key| { + const color = colors.get(key) orelse continue; + if (!started) { + try json.objectField(@tagName(scheme)); + try json.beginObject(); + started = true; + } + var buf: [7:0]u8 = undefined; + try json.objectField(@tagName(key)); + try json.write(color.hex(&buf)); + } + if (started) try json.endObject(); + } + try json.endObject(); + // Written even when empty, so that the key is discoverable in a file // someone has opened to see what they can put in it. try json.objectField("startup"); @@ -416,3 +528,111 @@ fn configDir(buf: []u8) ?[:0]const u8 { const dir = std.mem.span(glib.getUserConfigDir()); return std.fmt.bufPrintZ(buf, "{s}/playpen", .{dir}) catch null; } + +// ------------------------------------------------------------------------- +// Tests +// +// Reading and writing the file, minus the file: `parse` and `serialize` are +// where the on-disk shape actually lives, and they need nothing but an +// allocator. The palette overrides are what these are here for — a colour is a +// name and a hex string in a nested object, which is three chances to write out +// something the next launch reads back as a different palette. + +fn forTesting() Settings { + return .{ .arena = .init(std.testing.allocator) }; +} + +test "palette overrides are read per scheme" { + var settings = forTesting(); + defer settings.arena.deinit(); + + try settings.parse( + \\{"theme": "dark", "colors": { + \\ "dark": {"accent": "#ff8800", "ansi_red": "#112233"}, + \\ "light": {"sidebar": "#fafafa"} + \\}} + ); + + 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).?); + + // 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)); +} + +test "a colour we can't read costs that colour and nothing else" { + var settings = forTesting(); + defer settings.arena.deinit(); + + try settings.parse( + \\{"theme": "dark", "colors": {"dark": { + \\ "not_a_colour": "#123456", + \\ "border": "rebeccapurple", + \\ "sidebar": 12, + \\ "accent": "#ff8800" + \\}}} + ); + + 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)); + + // 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).?); +} + +test "colors is written even when nothing has been changed" { + 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, "\"colors\": {}") != null); +} + +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").?); + + const text = try settings.serialize(); + defer std.testing.allocator.free(text); + + try std.testing.expect(std.mem.indexOf(u8, text, "\"accent\": \"#ff8800\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"ansi_bright_white\": \"#010203\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"text\": \"#040506\"") != null); + + // Nothing else, so a file stays as short as the changes it records. + try std.testing.expect(std.mem.indexOf(u8, text, "\"sidebar\"") == null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"ansi_red\"") == null); + + var read_back = forTesting(); + defer read_back.arena.deinit(); + 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)); + } +} + +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").?); + + const text = try settings.serialize(); + defer std.testing.allocator.free(text); + + try std.testing.expect(std.mem.indexOf(u8, text, "\"dark\"") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "\"light\"") == null); +} diff --git a/src/SettingsDialog.zig b/src/SettingsDialog.zig index fb1b46c..aa865da 100644 --- a/src/SettingsDialog.zig +++ b/src/SettingsDialog.zig @@ -32,6 +32,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const Layouts = @import("Layouts.zig"); +const PaletteEditor = @import("PaletteEditor.zig"); const Settings = @import("Settings.zig"); const appearance = @import("appearance.zig"); @@ -95,6 +96,10 @@ ctx: ?*anyopaque, /// re-checked without asking each button what it stands for. theme_buttons: [std.enums.values(Settings.Theme).len]*gtk.ToggleButton, +/// The palette editor under the theme picker. Held because changing the scheme +/// changes which palette it is editing, so it has to be told. +colors: *PaletteEditor, + /// Container the startup rows live in, so rows can be added and removed after /// the page is already on screen. startup_box: *gtk.Box, @@ -129,10 +134,17 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo .on_capture = opts.on_capture, .ctx = opts.ctx, .theme_buttons = undefined, + .colors = undefined, .startup_box = gtk.Box.new(.vertical, 6), .error_label = gtk.Label.new(null), }; + self.colors = try PaletteEditor.create(alloc, .{ + .on_report = &onEditorReport, + .ctx = self, + }); + errdefer self.colors.destroy(); + self.window.setTitle("Settings"); self.window.setTransientFor(parent); self.window.setModal(1); @@ -196,7 +208,7 @@ pub fn close() void { // ------------------------------------------------------------------------- // Appearance -/// The Appearance section: one row, the colour scheme. +/// The Appearance section: the colour scheme, and the palette it resolves to. fn buildAppearance(self: *SettingsDialog) *gtk.Widget { const group = gtk.Box.new(.vertical, 10); group.as(gtk.Widget).addCssClass("playpen-settings-group"); @@ -227,10 +239,19 @@ fn buildAppearance(self: *SettingsDialog) *gtk.Widget { row.append(self.buildThemeChoice()); group.append(row.as(gtk.Widget)); + group.append(self.colors.widget()); return group.as(gtk.Widget); } +/// The palette editor's half of the error label the startup list below already +/// reports through, so a settings file that can't be written says so in one +/// place however the edit arrived. +fn onEditorReport(ctx: ?*anyopaque, message: ?[:0]const u8) void { + const self: *SettingsDialog = @ptrCast(@alignCast(ctx.?)); + self.showError(message); +} + /// The scheme picker: one toggle per choice, drawn as a single linked control. /// /// They are not put in a GTK radio group. Grouped toggles can't be unchecked by @@ -292,6 +313,10 @@ fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c) } appearance.setTheme(values[index]); + + // The palette editor edits whichever scheme is on screen, and that is what + // just changed: its swatches now stand for the other palette's colours. + self.colors.refresh(); } // ------------------------------------------------------------------------- @@ -757,6 +782,7 @@ fn onDestroy(_: *gtk.Window, self: *SettingsDialog) callconv(.c) void { for (self.startup.items) |row| self.freeRow(row); self.startup.deinit(self.alloc); + self.colors.destroy(); self.alloc.destroy(self); } diff --git a/src/Terminal.zig b/src/Terminal.zig index 3a6cd0b..8e0c1de 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -942,7 +942,7 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { // Background. The pane frame around us clips to its own rounded corners, // so this just fills. const default_bg: theme.Rgb = if (term.colors.background.get()) |c| - .from(c) + .fromVt(c) else theme.bg(); { @@ -952,7 +952,7 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { } const default_fg: theme.Rgb = if (term.colors.foreground.get()) |c| - .from(c) + .fromVt(c) else theme.fg(); @@ -1133,7 +1133,7 @@ fn drawCursor( const x = pad + @as(f64, @floatFromInt(cursor.x)) * self.cell_w; const y = pad + @as(f64, @floatFromInt(cursor.y)) * self.cell_h; - const color: theme.Rgb = if (term.colors.cursor.get()) |c| .from(c) else theme.cursor(); + const color: theme.Rgb = if (term.colors.cursor.get()) |c| .fromVt(c) else theme.cursor(); const r, const g, const b = color.cairoRgb(); cr.setSourceRgb(r, g, b); @@ -1191,7 +1191,7 @@ fn appearance( switch (cell.content_tag) { .bg_color_palette => return .{ .fg = default_fg, - .bg = .from(palette[cell.content.color_palette.data]), + .bg = .fromVt(palette[cell.content.color_palette.data]), .bold = false, .italic = false, .underline = false, @@ -1216,12 +1216,12 @@ fn appearance( var fg: theme.Rgb = switch (style.fg_color) { .none => default_fg, .palette => |i| brightIfBold(palette, i, style.flags.bold), - .rgb => |c| .from(c), + .rgb => |c| .fromVt(c), }; var bg: ?theme.Rgb = switch (style.bg_color) { .none => null, - .palette => |i| .from(palette[i]), - .rgb => |c| .from(c), + .palette => |i| .fromVt(palette[i]), + .rgb => |c| .fromVt(c), }; if (style.flags.inverse) { @@ -1247,5 +1247,5 @@ fn appearance( /// with the matching bright color. fn brightIfBold(palette: *const [256]vt.color.RGB, index: u8, bold: bool) theme.Rgb { const effective = if (bold and index < 8) index + 8 else index; - return .from(palette[effective]); + return .fromVt(palette[effective]); } diff --git a/src/appearance.zig b/src/appearance.zig index bf6b6eb..185a9ba 100644 --- a/src/appearance.zig +++ b/src/appearance.zig @@ -5,12 +5,12 @@ //! entries, scrollbars, dialog chrome. It is told to force light or dark, or //! left on `default` so it follows the desktop. //! * **`style.css`** colours everything the app draws for itself. It is -//! written entirely against named colours, with one palette file per scheme; -//! the matching palette is prepended and the pair loaded as a single +//! written entirely against named colours; `palette.zig` supplies their +//! values for the scheme in force, and the two are loaded as a single //! provider. -//! * **`theme.zig`** holds the three fallback colours the terminal renderer -//! reads. Cairo never consults the style tree, so a CSS reload on its own -//! would leave every terminal grid painted in the palette it started with. +//! * **`theme.zig`** holds the colours the terminal renderer reads. Cairo never +//! consults the style tree, so a CSS reload on its own would leave every +//! terminal grid painted in the palette it started with. //! //! The ordering that makes this work: the user's preference is only ever //! *told to libadwaita*, and the repaint is driven by libadwaita's answer. @@ -18,6 +18,10 @@ //! desktop for the setting and reports it as the `dark` property, so following //! that property means a desktop that changes scheme at sunset repaints this //! app too, with no extra machinery and no second source of truth. +//! +//! Editing a colour comes in through `refresh`, which is the same repaint +//! without the trip through libadwaita: the scheme hasn't changed, only what it +//! is painted in. const std = @import("std"); const adw = @import("adw"); @@ -26,14 +30,34 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const Settings = @import("Settings.zig"); +const palette = @import("palette.zig"); const theme = @import("theme.zig"); -/// The stylesheet, once per scheme. Concatenated at compile time so that a -/// switch is a single `loadFromString` with no allocation and no file IO at -/// the moment the desktop changes its mind. +/// The stylesheet, which is the same in both schemes: every colour in it is a +/// name, and the names are defined by the block `palette.zig` writes ahead of +/// it. const style = @embedFile("style.css"); -const css_dark = @embedFile("palette-dark.css") ++ style; -const css_light = @embedFile("palette-light.css") ++ style; + +/// The two are handed to GTK as one string, so they live in one buffer. Static +/// rather than allocated: this is written on every scheme change and on every +/// colour edited, and a repaint that can fail for want of memory would be a +/// stylesheet that half applies. +var css_buf: [palette.css_size + style.len + 1]u8 = undefined; + +/// Build the provider's stylesheet for `scheme`: the palette, then the rules +/// written against it. +fn buildCss(scheme: palette.Scheme) [:0]const u8 { + var writer: std.Io.Writer = .fixed(&css_buf); + + // The buffer is sized from the same table the palette is written out of, so + // there is no case in which this doesn't fit. + palette.writeCss(scheme, Settings.get().colors.of(scheme), &writer) catch unreachable; + writer.writeAll(style) catch unreachable; + writer.writeByte(0) catch unreachable; + + const written = writer.buffered(); + return written[0 .. written.len - 1 :0]; +} /// Called after the scheme changes, so the owner can repaint the things GTK /// doesn't know are colour-dependent — the Cairo-drawn terminal grids. @@ -115,13 +139,27 @@ fn onDarkChanged( repaint(); } +/// Repaint in the palette as it now stands, without touching the preference. +/// +/// This is what the colour editor calls after changing a colour: the scheme is +/// whatever it already was, and everything painted from it — the stylesheet, the +/// terminal grids — is rebuilt from the new value. +pub fn refresh() void { + repaint(); +} + /// Bring the stylesheet and the terminal palette to whatever libadwaita has /// settled on. Idempotent, and cheap enough to call speculatively. fn repaint() void { const dark = adw.StyleManager.getDefault().getDark() != 0; + const scheme: palette.Scheme = if (dark) .dark else .light; - theme.setScheme(if (dark) .dark else .light); - if (provider) |css| css.loadFromString(if (dark) css_dark else css_light); + // Before the stylesheet: both are built from the settings, and this is the + // one that re-resolves them, so doing it first means the widgets and the + // terminal grids are painted from the same palette rather than from either + // side of an edit. + theme.setScheme(scheme); + if (provider) |css| css.loadFromString(buildCss(scheme)); if (on_changed) |cb| cb(on_changed_ctx); } diff --git a/src/palette-dark.css b/src/palette-dark.css deleted file mode 100644 index 32afcef..0000000 --- a/src/palette-dark.css +++ /dev/null @@ -1,47 +0,0 @@ -/* Dark palette: the colour half of the stylesheet. - * - * `appearance.zig` prepends exactly one of `palette-dark.css` or - * `palette-light.css` to `style.css` and loads the pair as a single provider, - * so every named colour below has a counterpart in the other file. Adding one - * here means adding it there too — a name defined in only one palette is a - * rule that silently stops applying in the other scheme. - * - * Deep navy rather than neutral grey, lifted a little at each step so the - * three surfaces (window, sidebar, pane) separate without any of them - * reading as light. `pp_surface` is duplicated in `theme.zig` as the terminal - * renderer's default background; the two have to stay equal or the frame - * around a terminal stops matching its contents. */ - -/* Surfaces, darkest first. The window colour shows through as the gutter - between panes, so it sits below the panes rather than beside them. */ -@define-color pp_bg #070c15; -@define-color pp_sidebar #0b1320; -@define-color pp_surface #0e1624; -@define-color pp_surface_raised #131f30; -@define-color pp_surface_raised_active #182740; - -@define-color pp_border #1d2b3f; -@define-color pp_border_strong #2a3c55; - -/* Text, brightest first. `faint` is for things that are labelled rather than - read — pane titles of panes you aren't in, dialog hints. */ -@define-color pp_text #dde6f4; -@define-color pp_text_dim #9aabc2; -@define-color pp_text_faint #6a7c93; - -@define-color pp_row_hover #162233; -@define-color pp_row_selected #1c2c45; - -/* Signal's ultramarine. `strong` is the variant that has to stay legible as a - small mark on a surface — icons, dots, the state bar down a row's edge — - which is why it gets brighter here and darker in the light palette. */ -@define-color pp_accent #3a76f0; -@define-color pp_accent_strong #6191f3; -@define-color pp_accent_muted #2f5296; - -/* The three states that are news. Kept away from the accent on purpose: the - accent means "here" and these mean "something happened", and a sidebar - where those are the same colour answers neither question. */ -@define-color pp_ok #3ecf8e; -@define-color pp_warn #f0b849; -@define-color pp_err #f2717b; diff --git a/src/palette-light.css b/src/palette-light.css deleted file mode 100644 index a0172f3..0000000 --- a/src/palette-light.css +++ /dev/null @@ -1,36 +0,0 @@ -/* Light palette. See `palette-dark.css` for what each name is for; this file - * answers the same questions with light surfaces and dark ink. - * - * Not an inversion of the dark palette. Two things flip meaning rather than - * value: - * - * * `pp_accent_strong` is *darker* than `pp_accent` here. Its job is to stay - * legible as a small mark against the surface it sits on, and on a white - * row that means going down, not up. - * * The three state colours are pulled well away from their dark-palette - * values. A dot in #3ecf8e reads clearly on navy and disappears on white, - * so each one is darkened until it carries against paper. */ - -@define-color pp_bg #d9e1ee; -@define-color pp_sidebar #edf1f8; -@define-color pp_surface #ffffff; -@define-color pp_surface_raised #f2f6fc; -@define-color pp_surface_raised_active #e3edfd; - -@define-color pp_border #ccd8e8; -@define-color pp_border_strong #adbdd4; - -@define-color pp_text #161f2e; -@define-color pp_text_dim #4c5c73; -@define-color pp_text_faint #77879d; - -@define-color pp_row_hover #e1e9f5; -@define-color pp_row_selected #d2e1fd; - -@define-color pp_accent #2c6bed; -@define-color pp_accent_strong #1851b4; -@define-color pp_accent_muted #a9c4f7; - -@define-color pp_ok #12805a; -@define-color pp_warn #8a5a00; -@define-color pp_err #c23b47; diff --git a/src/palette.zig b/src/palette.zig new file mode 100644 index 0000000..34863cb --- /dev/null +++ b/src/palette.zig @@ -0,0 +1,633 @@ +//! The palette: every colour the app paints itself with, what each one defaults +//! to in either scheme, and the shape an override takes when someone changes it. +//! +//! This used to be two stylesheets — `palette-dark.css` and +//! `palette-light.css`, one of them prepended to `style.css` at compile time — +//! and that was the right shape right up until the settings page grew a colour +//! editor. An editor needs the *names*, to list them; the *defaults*, to say +//! which ones have been changed and to put them back; and some *grouping*, so +//! that the list reads as a palette rather than as forty hex fields. None of +//! that can be had from a CSS file without parsing it, and one table that +//! answers all of it beats a stylesheet plus a table that has to agree with it. +//! +//! So the CSS is generated from here instead: `writeCss` emits one +//! `@define-color` per name, `appearance.zig` prepends the result to +//! `style.css`, and `theme.zig` resolves the terminal renderer's colours out of +//! the same table. There is still exactly one place a colour is written down. +//! +//! Overrides are held per scheme and only where they exist. That is what makes +//! "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. + +const std = @import("std"); +const vt = @import("ghostty-vt"); + +pub const Rgb = struct { + r: u8, + g: u8, + b: u8, + + pub fn fromVt(c: vt.color.RGB) Rgb { + return .{ .r = c.r, .g = c.g, .b = c.b }; + } + + pub fn toVt(self: Rgb) vt.color.RGB { + return .{ .r = self.r, .g = self.g, .b = self.b }; + } + + /// Cairo takes color channels as 0..1 doubles. + pub fn cairoRgb(self: Rgb) struct { f64, f64, f64 } { + return .{ + @as(f64, @floatFromInt(self.r)) / 255.0, + @as(f64, @floatFromInt(self.g)) / 255.0, + @as(f64, @floatFromInt(self.b)) / 255.0, + }; + } + + pub fn eql(self: Rgb, other: Rgb) bool { + return self.r == other.r and self.g == other.g and self.b == other.b; + } + + /// `#rrggbb`, written into the caller's buffer. + /// + /// The buffer is the caller's because every use of this is a label or a + /// line of CSS that is about to be handed to C and then forgotten, and a + /// seven-byte array on the stack is the whole allocation story. + pub fn hex(self: Rgb, buf: *[7:0]u8) [:0]const u8 { + _ = std.fmt.bufPrint(buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ + self.r, self.g, self.b, + }) catch unreachable; + buf[7] = 0; + return buf[0..7 :0]; + } + + /// `#rrggbb` or `rrggbb`, case-insensitively. Anything else is `null` + /// rather than an error: the only callers are a settings file someone may + /// have hand-edited and a compile-time table, and both want "no" for an + /// answer they can act on. + pub fn parse(text: []const u8) ?Rgb { + const digits = if (text.len > 0 and text[0] == '#') text[1..] else text; + if (digits.len != 6) return null; + + return .{ + .r = std.fmt.parseInt(u8, digits[0..2], 16) catch return null, + .g = std.fmt.parseInt(u8, digits[2..4], 16) catch return null, + .b = std.fmt.parseInt(u8, digits[4..6], 16) catch return null, + }; + } +}; + +/// A resolved colour scheme. Not the same thing as the user's preference, which +/// has a third option — see `Settings.Theme`. By the time a scheme reaches this +/// module, "system" has been resolved to one of these. +pub const Scheme = enum { + light, + dark, + + /// How the editor names the palette being edited. + pub fn label(self: Scheme) [:0]const u8 { + return switch (self) { + .light => "light", + .dark => "dark", + }; + } +}; + +/// The sections the editor lays the palette out in, and the order it lays them +/// out in — which is also the order the keys are declared in below. +pub const Group = enum { + surfaces, + text, + rows, + accents, + states, + terminal, + ansi, + + /// Whether this group's colours reach the UI as named CSS colours. The two + /// that don't are the terminal's: Cairo draws the grid itself and never + /// consults the style tree. + pub fn isCss(self: Group) bool { + return switch (self) { + .surfaces, .text, .rows, .accents, .states => true, + .terminal, .ansi => false, + }; + } + + pub fn label(self: Group) [:0]const u8 { + return switch (self) { + .surfaces => "Surfaces", + .text => "Text", + .rows => "Tab rows", + .accents => "Accent", + .states => "Status", + .terminal => "Terminal", + .ansi => "Terminal ANSI colours", + }; + } + + /// One line under the heading, where the group needs one. + pub fn hint(self: Group) ?[:0]const u8 { + return switch (self) { + .surfaces => "Darkest first: the window backdrop shows through as the gutter between panes.", + .terminal => "What a program in the terminal gets before it asks for anything else. " ++ + "Left alone, these follow the surface, text and accent colours above.", + .ansi => "The 16 colours programs ask for by name. " ++ + "The 240 above them are fixed by spec and not editable.", + .text, .rows, .accents, .states => null, + }; + } +}; + +/// Every colour in the palette. +/// +/// The tag is the wire format — it is what a colour is called in +/// `settings.json` — and for everything in a CSS group it is also the name in +/// the stylesheet, with `pp_` in front. Renaming one is therefore a migration, +/// which is why the names here are the ones `style.css` already used. +/// +/// Declaration order is the order the editor lists them in, grouped. +pub const Key = enum { + // ---- surfaces, darkest first ---- + bg, + sidebar, + surface, + surface_raised, + surface_raised_active, + border, + border_strong, + + // ---- text, brightest first ---- + text, + text_dim, + text_faint, + + // ---- tab rows ---- + row_hover, + row_selected, + + // ---- accent ---- + accent, + accent_strong, + accent_muted, + + // ---- status ---- + ok, + warn, + err, + + // ---- terminal ---- + term_bg, + term_fg, + term_cursor, + term_selection_bg, + term_selection_fg, + + // ---- the 16 named ANSI colours, in palette order ---- + ansi_black, + ansi_red, + ansi_green, + ansi_yellow, + ansi_blue, + ansi_magenta, + ansi_cyan, + ansi_white, + ansi_bright_black, + ansi_bright_red, + ansi_bright_green, + ansi_bright_yellow, + ansi_bright_blue, + ansi_bright_magenta, + ansi_bright_cyan, + ansi_bright_white, + + pub fn group(self: Key) Group { + return switch (self) { + .bg, + .sidebar, + .surface, + .surface_raised, + .surface_raised_active, + .border, + .border_strong, + => .surfaces, + .text, .text_dim, .text_faint => .text, + .row_hover, .row_selected => .rows, + .accent, .accent_strong, .accent_muted => .accents, + .ok, .warn, .err => .states, + .term_bg, .term_fg, .term_cursor, .term_selection_bg, .term_selection_fg => .terminal, + else => .ansi, + }; + } + + /// The name `style.css` knows this colour by, for the groups that reach CSS + /// at all. Derived from the tag rather than written out again, so the two + /// cannot drift. + pub fn cssName(self: Key) ?[:0]const u8 { + return switch (self) { + inline else => |key| comptime if (key.group().isCss()) + "pp_" ++ @tagName(key) + else + null, + }; + } + + /// This colour's index in the 256-colour palette, for the 16 that have one. + pub fn ansiIndex(self: Key) ?u8 { + if (self.group() != .ansi) return null; + return @intCast(@intFromEnum(self) - @intFromEnum(Key.ansi_black)); + } + + /// The colour this one falls back to when it hasn't been set. + /// + /// This is what keeps the terminal and the frame around it looking like one + /// surface without asking anyone to set the same colour twice. The + /// terminal's background *is* the pane surface, its foreground *is* the + /// body text, and a selection *is* the muted accent — until someone says + /// otherwise, at which point they come apart, which is the whole reason + /// they are separate keys. + pub fn inherits(self: Key) ?Key { + return switch (self) { + .term_bg => .surface, + .term_fg => .text, + .term_selection_bg => .accent_muted, + .term_selection_fg => .text, + else => null, + }; + } + + pub fn label(self: Key) [:0]const u8 { + return switch (self) { + .bg => "Window backdrop", + .sidebar => "Sidebar", + .surface => "Pane surface", + .surface_raised => "Pane header", + .surface_raised_active => "Pane header, focused", + .border => "Borders", + .border_strong => "Borders, strong", + + .text => "Text", + .text_dim => "Text, dim", + .text_faint => "Text, faint", + + .row_hover => "Hovered", + .row_selected => "Selected", + + .accent => "Accent", + .accent_strong => "Accent, strong", + .accent_muted => "Accent, muted", + + .ok => "Finished", + .warn => "Waiting on you", + .err => "Failed", + + .term_bg => "Background", + .term_fg => "Foreground", + .term_cursor => "Cursor", + .term_selection_bg => "Selection", + .term_selection_fg => "Selected text", + + .ansi_black => "Black", + .ansi_red => "Red", + .ansi_green => "Green", + .ansi_yellow => "Yellow", + .ansi_blue => "Blue", + .ansi_magenta => "Magenta", + .ansi_cyan => "Cyan", + .ansi_white => "White", + .ansi_bright_black => "Bright black", + .ansi_bright_red => "Bright red", + .ansi_bright_green => "Bright green", + .ansi_bright_yellow => "Bright yellow", + .ansi_bright_blue => "Bright blue", + .ansi_bright_magenta => "Bright magenta", + .ansi_bright_cyan => "Bright cyan", + .ansi_bright_white => "Bright white", + }; + } + + /// What this colour is for, for the ones where the name isn't the whole + /// story. Shown as the row's tooltip. + pub fn hint(self: Key) ?[:0]const u8 { + return switch (self) { + .bg => "Behind everything, and the gutter between panes", + .surface_raised => "The strip above a pane holding its title", + .border_strong => "Edges that have to carry on their own, without a fill behind them", + .text_dim => "Secondary labels — hints, counts, addresses", + .text_faint => "Things that are labelled rather than read, like the title of a pane you aren't in", + .accent_strong => "Small marks that have to stay legible on a surface: icons, dots, a row's state bar", + .accent_muted => "Washes and the border of the focused pane", + .ok => "A pane that finished and hasn't been looked at", + .warn => "A pane waiting for you to answer something", + .err => "A pane that failed", + .term_cursor => "The cursor block. Not the accent: on a light background a bright block swallows the character under it", + .term_selection_fg => "Fixed rather than kept from the text underneath, so a selection reads as one block", + else => null, + }; + } + + /// The colour before any override: this key's own default, or the default of + /// whatever it inherits from. + pub fn default(self: Key, scheme: Scheme) Rgb { + if (self.ansiIndex()) |i| return switch (scheme) { + // libghostty-vt's own palette, so the terminal's idea of "red" and + // ours are the same one. + .dark => .fromVt(vt.color.default[i]), + .light => light_ansi[i], + }; + + const table: *const Table = switch (scheme) { + .dark => &dark, + .light => &light, + }; + + if (table.get(self)) |color| return color; + return self.inherits().?.default(scheme); + } +}; + +pub const count = std.enums.values(Key).len; + +/// The colours someone has changed, per key, in one scheme. A fixed array +/// rather than a list of pairs: it is 120 bytes, it needs no allocator, and it +/// makes "is this key overridden" a lookup rather than a search. +pub const Overrides = std.enums.EnumArray(Key, ?Rgb); + +pub const no_overrides: Overrides = .initFill(null); + +/// The colour to paint with: the override if there is one, else what the key +/// inherits (overrides and all), 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); + 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. +/// +/// `appearance.zig` prepends this to the stylesheet and loads the pair as a +/// single provider. Only the CSS groups appear; the terminal's colours go to +/// Cairo instead, through `theme.zig`. +pub fn writeCss( + scheme: Scheme, + overrides: *const Overrides, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { + for (std.enums.values(Key)) |key| { + const name = key.cssName() orelse continue; + var buf: [7:0]u8 = undefined; + try writer.print("@define-color {s} {s};\n", .{ + name, + resolve(key, scheme, overrides).hex(&buf), + }); + } +} + +/// An upper bound on what `writeCss` writes, so its caller can hold a buffer +/// rather than an allocator: the longest line the loop can emit, once per key. +pub const css_size = count * ("@define-color pp_surface_raised_active #rrggbb;\n".len); + +// ------------------------------------------------------------------------- +// The defaults +// +// Two tables, one per scheme, in the same shape and the same order as the two +// CSS files they replace. A key that inherits (see `Key.inherits`) is absent +// from both rather than repeating the colour it would have copied. + +const Table = std.enums.EnumArray(Key, ?Rgb); + +/// The default for a key the table below doesn't mention: no colour of its own, +/// meaning it inherits one or is an ANSI colour taken from libghostty-vt. Named +/// because it has to be typed as an *inner* null — an outer one would mean "this +/// table has no default" and make every key mandatory. +const unset: ?Rgb = null; + +/// Deep navy rather than neutral grey, lifted a little at each step so the +/// three surfaces — window, sidebar, pane — separate without any of them +/// reading as light. +const dark: Table = .initDefault(unset, .{ + .bg = h("#070c15"), + .sidebar = h("#0b1320"), + .surface = h("#0e1624"), + .surface_raised = h("#131f30"), + .surface_raised_active = h("#182740"), + .border = h("#1d2b3f"), + .border_strong = h("#2a3c55"), + + .text = h("#dde6f4"), + .text_dim = h("#9aabc2"), + .text_faint = h("#6a7c93"), + + .row_hover = h("#162233"), + .row_selected = h("#1c2c45"), + + // Signal's ultramarine. `strong` is the variant that has to stay legible as + // a small mark on a surface, which is why it goes brighter here and darker + // in the light palette. + .accent = h("#3a76f0"), + .accent_strong = h("#6191f3"), + .accent_muted = h("#2f5296"), + + // The three states that are news. Kept away from the accent on purpose: the + // accent means "here" and these mean "something happened", and a sidebar + // where those are the same colour answers neither question. + .ok = h("#3ecf8e"), + .warn = h("#f0b849"), + .err = h("#f2717b"), + + // The cursor is the one terminal colour that is nobody else's: it goes to + // the bright accent here and *down* to the mid accent on white, because on + // a near-white background a bright block swallows the character under it. + .term_cursor = h("#6191f3"), +}); + +/// Not an inversion of the dark palette. `accent_strong` is *darker* than +/// `accent` here, for the same reason it is brighter there — its job is to +/// carry as a small mark against the surface it sits on. The three state +/// colours are pulled well away from their dark values too: a dot in `#3ecf8e` +/// reads clearly on navy and disappears on white. +/// +/// The text stops short of black. Full-contrast black on white is harsher to +/// read a screen of text against than a very dark navy is. +const light: Table = .initDefault(unset, .{ + .bg = h("#d9e1ee"), + .sidebar = h("#edf1f8"), + .surface = h("#ffffff"), + .surface_raised = h("#f2f6fc"), + .surface_raised_active = h("#e3edfd"), + .border = h("#ccd8e8"), + .border_strong = h("#adbdd4"), + + .text = h("#161f2e"), + .text_dim = h("#4c5c73"), + .text_faint = h("#77879d"), + + .row_hover = h("#e1e9f5"), + .row_selected = h("#d2e1fd"), + + .accent = h("#2c6bed"), + .accent_strong = h("#1851b4"), + .accent_muted = h("#a9c4f7"), + + .ok = h("#12805a"), + .warn = h("#8a5a00"), + .err = h("#c23b47"), + + .term_cursor = h("#2c6bed"), +}); + +/// The 16 named colours, retuned for a light background. +/// +/// This is the part of a light scheme that is easy to skip and shouldn't be: +/// libghostty-vt's default yellow is a mid tan and the standard xterm one is +/// `#cdcd00`, and every prompt and build tool reaches for it. +/// +/// Two entries change meaning rather than brightness. `white` (7) and `bright +/// white` (15) are the foreground half of "white text", and on a white +/// background they have to go dark or the text they colour disappears +/// altogether. Every light terminal theme makes this trade: a program that +/// asked for a white *background* gets a dark block instead, which is jarring +/// but rare, and the alternative is text that cannot be read at all, which is +/// neither. +const light_ansi = [16]Rgb{ + h("#1c2430"), // black + h("#c2261f"), // red + h("#1a7f37"), // green + h("#8a6100"), // yellow + h("#1851b4"), // blue + h("#9333a8"), // magenta + h("#0f6f83"), // cyan + h("#4c5c73"), // white + h("#6a7c93"), // bright black + h("#d63a30"), // bright red + h("#1f9d4d"), // bright green + h("#a87400"), // bright yellow + h("#2c6bed"), // bright blue + h("#b13cc9"), // bright magenta + h("#1289a3"), // bright cyan + h("#161f2e"), // bright white +}; + +/// A hex literal for the tables above, which is where the readability is: a +/// palette wants to be read as the colours a designer wrote down, not as sixty +/// structs of three numbers. +inline fn h(comptime text: []const u8) Rgb { + comptime { + // Sixty of these are parsed at compile time, and the integer parser they + // go through is not cheap by the branch counter's reckoning. + @setEvalBranchQuota(100_000); + return Rgb.parse(text) orelse @compileError("not a colour: " ++ text); + } +} + +// Every key has to end up with a colour, or the palette has a hole in it that +// nothing will report until something is painted with it. +comptime { + for (std.enums.values(Key)) |key| { + if (key.ansiIndex() != null) continue; + if (key.inherits() != null) continue; + if (dark.get(key) == null) @compileError("no dark default for " ++ @tagName(key)); + if (light.get(key) == null) @compileError("no light default for " ++ @tagName(key)); + } +} + +// ------------------------------------------------------------------------- + +test "hex round trip" { + const cases = [_][]const u8{ "#070c15", "#ffffff", "#000000", "#3a76f0" }; + for (cases) |text| { + const color = Rgb.parse(text).?; + var buf: [7:0]u8 = undefined; + try std.testing.expectEqualStrings(text, color.hex(&buf)); + } +} + +test "hex parsing" { + try std.testing.expectEqual(Rgb{ .r = 0x07, .g = 0x0c, .b = 0x15 }, Rgb.parse("#070c15").?); + + // Without the hash, and in upper case: both come from people pasting. + try std.testing.expectEqual(Rgb{ .r = 0x07, .g = 0x0c, .b = 0x15 }, Rgb.parse("070C15").?); + + try std.testing.expectEqual(@as(?Rgb, null), Rgb.parse("")); + try std.testing.expectEqual(@as(?Rgb, null), Rgb.parse("#fff")); + try std.testing.expectEqual(@as(?Rgb, null), Rgb.parse("#ggggggg")); + try std.testing.expectEqual(@as(?Rgb, null), Rgb.parse("#0000000")); + try std.testing.expectEqual(@as(?Rgb, null), Rgb.parse("rebeccapurple")); +} + +test "css names cover the stylesheet's groups and nothing else" { + try std.testing.expectEqualStrings("pp_surface_raised_active", Key.surface_raised_active.cssName().?); + try std.testing.expectEqualStrings("pp_err", Key.err.cssName().?); + try std.testing.expectEqual(@as(?[:0]const u8, null), Key.term_bg.cssName()); + try std.testing.expectEqual(@as(?[:0]const u8, null), Key.ansi_red.cssName()); +} + +test "ansi keys map to palette indices in order" { + try std.testing.expectEqual(@as(?u8, 0), Key.ansi_black.ansiIndex()); + try std.testing.expectEqual(@as(?u8, 7), Key.ansi_white.ansiIndex()); + try std.testing.expectEqual(@as(?u8, 15), Key.ansi_bright_white.ansiIndex()); + try std.testing.expectEqual(@as(?u8, null), Key.accent.ansiIndex()); +} + +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)); +} + +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), + ); + + // ...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)); + + // ...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)); +} + +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); + + const css = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, css, "@define-color pp_bg #070c15;\n") != null); + try std.testing.expect(std.mem.indexOf(u8, css, "@define-color pp_accent #ff8800;\n") != null); + try std.testing.expect(std.mem.indexOf(u8, css, "pp_term_bg") == null); + try std.testing.expect(std.mem.indexOf(u8, css, "pp_ansi_red") == null); + + var lines: usize = 0; + for (css) |c| { + if (c == '\n') lines += 1; + } + var named: usize = 0; + for (std.enums.values(Key)) |key| { + if (key.cssName() != null) named += 1; + } + try std.testing.expectEqual(named, lines); +} diff --git a/src/style.css b/src/style.css index e571255..3fd9b7e 100644 --- a/src/style.css +++ b/src/style.css @@ -1,11 +1,12 @@ /* Zen-style vertical tabs: a sidebar column with the terminal inset to its right. - Every colour here is a name defined in `palette-dark.css` or - `palette-light.css`; `appearance.zig` prepends one of them and loads the - pair as a single provider. Nothing below may hardcode a colour — a literal - hex would be a rule that looks right in one scheme and wrong in the other, - and it would look right in whichever one you happened to be testing in. + Every colour here is a name from `palette.zig`, which writes the matching + `@define-color` block for the scheme in force; `appearance.zig` prepends that + and loads the pair as a single provider. Nothing below may hardcode a colour — + a literal hex would be a rule that looks right in one scheme and wrong in the + other, it would look right in whichever one you happened to be testing in, + and the colour editor in the settings page could not reach it at all. `alpha()` is used for every wash rather than a second named colour, so a state's tint is always derived from the state's own colour and the two @@ -565,6 +566,51 @@ button.playpen-header-button:hover, border-color: @pp_accent_strong; } +/* ------------------------------------------------------------------------- + The colour editor + + One row per colour in the palette, in sections. The rows are deliberately + plain: on a page where thirty-nine swatches are the point, the swatch should + be the only thing on a line carrying any colour, and a bordered card per + section would read as noise. The space between sections does that work + instead. */ + +.playpen-color-group { + margin-top: 2px; +} + +.playpen-color-group-title { + font-size: 0.9em; + font-weight: bold; + color: @pp_text_dim; +} + +/* The value beside the swatch, so a palette can be read off and copied out as + text rather than only poked at through a picker. */ +.playpen-color-hex { + font-family: monospace; + font-size: 0.85em; + color: @pp_text_faint; +} + +/* Adwaita gives a colour button the full button treatment: a gradient over the + colour, and a border in a grey belonging to neither palette. What is wanted + is a rectangle of the colour with this app's border around it, so the swatch + reads as the colour rather than as a button holding one. */ +.playpen-color-group colorbutton > button { + padding: 0; + min-width: 0; + min-height: 0; + background-image: none; + box-shadow: none; + border: 1px solid @pp_border; + border-radius: 6px; +} + +.playpen-color-group colorbutton > button:hover { + border-color: @pp_border_strong; +} + /* ------------------------------------------------------------------------- The startup list diff --git a/src/theme.zig b/src/theme.zig index 6ed7dc7..3361132 100644 --- a/src/theme.zig +++ b/src/theme.zig @@ -1,175 +1,117 @@ -//! Colors for the parts of the UI that libghostty-vt has no opinion about. +//! The colours the terminal renderer draws with, resolved for the scheme the +//! app is currently in. //! -//! The 256-color palette itself comes from the terminal's own color state -//! (`Terminal.colors.palette`), which libghostty-vt initializes to the -//! standard xterm palette and keeps updated as programs change it via OSC. +//! Everything the *widgets* are painted with reaches them as named CSS colours +//! (see `palette.zig` and `appearance.zig`). The terminal grid can't work that +//! way: Cairo draws it directly and never consults the style tree, so a +//! stylesheet reload on its own would leave every open terminal painted in the +//! palette it started in. This module is the other half of that — the same +//! palette, resolved into plain numbers, for the renderer to read per frame. //! -//! What is left is the three colors a terminal falls back to when the program -//! running in it hasn't said otherwise — background, foreground and cursor — -//! and those have to flip with the rest of the window. They are kept here -//! rather than in the CSS because Cairo draws the terminal grid directly and -//! never consults the style tree; `appearance.zig` sets `scheme` at the same -//! moment it swaps the stylesheet, so the two stay in step. -//! -//! The values match `palette-dark.css` and `palette-light.css` deliberately: -//! `bg` is the same color as `@pp_surface` in each, so a terminal and the -//! frame drawn around it read as one surface rather than two. +//! Those numbers are cached rather than resolved on demand. The renderer asks +//! for the default background once per frame and for the ANSI palette on every +//! cell, and neither wants to go through the settings to get an answer that only +//! changes when the scheme or the palette does. `appearance.zig` is what tells +//! us it has, and it does so before it reloads the stylesheet, so the frame the +//! two produce together is one palette rather than half of each. +const std = @import("std"); const vt = @import("ghostty-vt"); -pub const Rgb = struct { - r: u8, - g: u8, - b: u8, +const Settings = @import("Settings.zig"); +const palette = @import("palette.zig"); - pub fn from(c: vt.color.RGB) Rgb { - return .{ .r = c.r, .g = c.g, .b = c.b }; - } +pub const Rgb = palette.Rgb; +pub const Scheme = palette.Scheme; - /// Cairo takes color channels as 0..1 doubles. - pub fn cairoRgb(self: Rgb) struct { f64, f64, f64 } { - return .{ - @as(f64, @floatFromInt(self.r)) / 255.0, - @as(f64, @floatFromInt(self.g)) / 255.0, - @as(f64, @floatFromInt(self.b)) / 255.0, - }; - } -}; - -/// A resolved color scheme. Not the same thing as the user's preference, -/// which has a third option — see `Settings.Theme`. By the time it reaches -/// here "system" has been resolved to one of these. -pub const Scheme = enum { light, dark }; - -const Palette = struct { +/// The colours of one scheme, already resolved through the user's overrides. +const Resolved = struct { /// Terminal default background, used when the program hasn't set one. bg: Rgb, /// Terminal default foreground. fg: Rgb, - /// Cursor block color. + /// Cursor block colour. cursor: Rgb, /// Background and foreground of selected text. Both are fixed rather than - /// derived from the cell underneath, because a selection has to read as - /// one continuous block across text the program has coloured every which - /// way — a translucent wash over the existing colors leaves the highlight + /// derived from the cell underneath, because a selection has to read as one + /// continuous block across text the program has coloured every which way — + /// a translucent wash over the existing colours leaves the highlight /// looking like a rendering artefact wherever the text is already bright. selection_bg: Rgb, selection_fg: Rgb, -}; -const dark: Palette = .{ - .bg = .{ .r = 0x0e, .g = 0x16, .b = 0x24 }, - .fg = .{ .r = 0xdd, .g = 0xe6, .b = 0xf4 }, - .cursor = .{ .r = 0x61, .g = 0x91, .b = 0xf3 }, - .selection_bg = .{ .r = 0x2f, .g = 0x52, .b = 0x96 }, - .selection_fg = .{ .r = 0xdd, .g = 0xe6, .b = 0xf4 }, -}; + /// The full 256-colour palette. The 240 above index 16 are fixed by the + /// xterm spec — a 6×6×6 cube and a grey ramp — and mean the same thing + /// whatever the background is, so they are left exactly as libghostty-vt + /// built them; only the 16 named ones are ours to set. + ansi: vt.color.Palette, -/// Not a straight inversion. The cursor darkens rather than lightens, because -/// on a near-white background a bright accent block swallows the character -/// underneath it, and the foreground stops short of black — full-contrast -/// black on white is harsher to read a screen of text against than a very -/// dark navy is. -const light: Palette = .{ - .bg = .{ .r = 0xff, .g = 0xff, .b = 0xff }, - .fg = .{ .r = 0x16, .g = 0x1f, .b = 0x2e }, - .cursor = .{ .r = 0x2c, .g = 0x6b, .b = 0xed }, - .selection_bg = .{ .r = 0xa9, .g = 0xc4, .b = 0xf7 }, - .selection_fg = .{ .r = 0x16, .g = 0x1f, .b = 0x2e }, + fn init(for_scheme: Scheme, overrides: *const palette.Overrides) 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), + .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(); + } + + return self; + } }; var scheme: Scheme = .dark; -/// Switch the palette the terminal renderer draws with. Does not repaint -/// anything by itself; `appearance.zig` owns that. +/// 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); + +/// Switch the palette the terminal renderer draws with, and re-resolve it +/// against whatever the user has changed. Does not repaint anything by itself; +/// `appearance.zig` owns that. pub fn setScheme(to: Scheme) void { scheme = to; + current = .init(to, Settings.get().colors.of(to)); } pub fn currentScheme() Scheme { return scheme; } -fn palette() Palette { - return switch (scheme) { - .dark => dark, - .light => light, - }; -} - pub fn bg() Rgb { - return palette().bg; + return current.bg; } pub fn fg() Rgb { - return palette().fg; + return current.fg; } pub fn cursor() Rgb { - return palette().cursor; + return current.cursor; } pub fn selectionBg() Rgb { - return palette().selection_bg; + return current.selection_bg; } pub fn selectionFg() Rgb { - return palette().selection_fg; + return current.selection_fg; } -// ------------------------------------------------------------------------- -// The ANSI palette -// -// The 240 colors above index 16 are fixed by the xterm spec — a 6×6×6 cube and -// a grey ramp — and mean the same thing whatever the background is, so they are -// left exactly as libghostty-vt built them. The first 16 are the ones programs -// actually reach for by name, and they are the reason a light terminal needs a -// palette at all: the standard xterm yellow is #cdcd00, which on white is close -// to invisible, and every prompt and build tool uses it. - -/// The 16 named colors, retuned for a light background. -/// -/// Two entries change meaning rather than brightness. `white` (7) and -/// `bright white` (15) are the foreground half of "white text", and on a white -/// background they have to go dark or the text they colour disappears -/// altogether. Every light terminal theme makes this trade: a program that -/// asked for a white *background* gets a dark block instead, which is jarring -/// but rare, and the alternative is text that cannot be read at all, which is -/// neither. -const light_ansi = [16]Rgb{ - .{ .r = 0x1c, .g = 0x24, .b = 0x30 }, // black - .{ .r = 0xc2, .g = 0x26, .b = 0x1f }, // red - .{ .r = 0x1a, .g = 0x7f, .b = 0x37 }, // green - .{ .r = 0x8a, .g = 0x61, .b = 0x00 }, // yellow - .{ .r = 0x18, .g = 0x51, .b = 0xb4 }, // blue - .{ .r = 0x93, .g = 0x33, .b = 0xa8 }, // magenta - .{ .r = 0x0f, .g = 0x6f, .b = 0x83 }, // cyan - .{ .r = 0x4c, .g = 0x5c, .b = 0x73 }, // white - .{ .r = 0x6a, .g = 0x7c, .b = 0x93 }, // bright black - .{ .r = 0xd6, .g = 0x3a, .b = 0x30 }, // bright red - .{ .r = 0x1f, .g = 0x9d, .b = 0x4d }, // bright green - .{ .r = 0xa8, .g = 0x74, .b = 0x00 }, // bright yellow - .{ .r = 0x2c, .g = 0x6b, .b = 0xed }, // bright blue - .{ .r = 0xb1, .g = 0x3c, .b = 0xc9 }, // bright magenta - .{ .r = 0x12, .g = 0x89, .b = 0xa3 }, // bright cyan - .{ .r = 0x16, .g = 0x1f, .b = 0x2e }, // bright white -}; - /// The palette a session should treat as its default under the current scheme. /// /// Handed to `DynamicPalette.changeDefault`, which is what makes this safe to /// call on a terminal that has been running for hours: anything the program -/// inside set with OSC 4 is preserved, and an OSC 104 reset later returns to -/// the scheme's palette rather than to the one the app started in. +/// inside set with OSC 4 is preserved, and an OSC 104 reset later returns to the +/// scheme's palette rather than to the one the app started in. pub fn ansiPalette() vt.color.Palette { - var colors = vt.color.default; - if (scheme == .light) { - for (light_ansi, 0..) |c, i| { - colors[i] = .{ .r = c.r, .g = c.g, .b = c.b }; - } - } - return colors; + return current.ansi; }