Add theme customizer.

This commit is contained in:
Greyson Parrelli
2026-08-13 15:55:23 -04:00
parent 2daeb6125f
commit 695417a601
12 changed files with 1661 additions and 244 deletions
+75 -8
View File
@@ -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:
+30
View File
@@ -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
+498
View File
@@ -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));
}
+226 -6
View File
@@ -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);
}
+27 -1
View File
@@ -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);
}
+8 -8
View File
@@ -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]);
}
+50 -12
View File
@@ -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);
}
-47
View File
@@ -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;
-36
View File
@@ -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;
+633
View File
@@ -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);
}
+51 -5
View File
@@ -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
+63 -121
View File
@@ -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;
}