Add theme customizer.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user