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
|
Pty.zig openpt/fork/exec, controlling terminal setup
|
||||||
script.zig runs a command for `$(...)` in a layout's directory
|
script.zig runs a command for `$(...)` in a layout's directory
|
||||||
key.zig GDK keyval -> libghostty-vt key mapping
|
key.zig GDK keyval -> libghostty-vt key mapping
|
||||||
theme.zig colors libghostty-vt has no opinion about, per scheme
|
palette.zig every colour by name, its default per scheme, and the CSS for it
|
||||||
Settings.zig preferences: theme and startup tabs, JSON on disk
|
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
|
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
|
TabSettingsDialog.zig one tab's own settings, and the emoji picker
|
||||||
emoji.zig generated: every emoji the picker offers, and the search over them
|
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
|
- **Light and dark schemes**, following the desktop by default and pinnable
|
||||||
from the settings page, applying to open tabs immediately — terminal palette
|
from the settings page, applying to open tabs immediately — terminal palette
|
||||||
included. See [Theme](#theme)
|
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
|
### 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
|
**Settings**, whose Appearance section holds the choice: light, dark, or
|
||||||
**system**, which is the default and follows the desktop.
|
**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
|
The preference lives in `~/.config/playpen/settings.json` (or
|
||||||
`$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the
|
`$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the
|
||||||
[startup tabs](#startup-tabs):
|
[startup tabs](#startup-tabs):
|
||||||
@@ -677,6 +687,7 @@ The preference lives in `~/.config/playpen/settings.json` (or
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"theme": "system",
|
"theme": "system",
|
||||||
|
"colors": {},
|
||||||
"startup": []
|
"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
|
dialog chrome. It is told to force a scheme, or left on `default` to follow
|
||||||
the desktop.
|
the desktop.
|
||||||
- **`style.css`** colours everything the app draws itself. It is written
|
- **`style.css`** colours everything the app draws itself. It is written
|
||||||
entirely against named colours, with one palette file per scheme
|
entirely against named colours; `palette.zig` writes the `@define-color`
|
||||||
(`palette-dark.css`, `palette-light.css`); the matching palette is prepended
|
block that defines them for the scheme in force, and the two are loaded as a
|
||||||
and the pair loaded as a single `GtkCssProvider`. No rule in `style.css` may
|
single `GtkCssProvider`. No rule in `style.css` may hardcode a colour — a
|
||||||
hardcode a colour — a literal hex is a rule that looks right in whichever
|
literal hex is a rule that looks right in whichever scheme you happened to be
|
||||||
scheme you happened to be testing in.
|
testing in, and one the colour editor cannot reach.
|
||||||
- **`theme.zig`** holds what Cairo draws the terminal grid from: the default
|
- **`theme.zig`** holds what Cairo draws the terminal grid from: the default
|
||||||
background, foreground and cursor, plus the 16 ANSI colours. The style tree
|
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
|
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
|
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
|
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
|
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.
|
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
|
## Not implemented
|
||||||
|
|
||||||
This is a proof of concept, and the following are deliberately absent:
|
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");
|
const test_step = b.step("test", "Run the tests");
|
||||||
test_step.dependOn(&b.addRunArtifact(tests).step);
|
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
|
// 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
|
// 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
|
// 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
|
//! something changes. It carries a version from the start, so adding the next
|
||||||
//! setting is not also the day we invent a format.
|
//! setting is not also the day we invent a format.
|
||||||
//!
|
//!
|
||||||
//! There are two settings, and they are very different shapes. The colour
|
//! The settings are three different shapes. The colour scheme is one word. The
|
||||||
//! scheme is one word. The **startup tabs** are a list: which saved layout to
|
//! **palette overrides** are a colour per name per scheme, and only for the
|
||||||
//! open, what to fill its parameters in with, and what to call the tab — one
|
//! names someone has actually changed. The **startup tabs** are a list: which
|
||||||
//! entry per tab, opened in order at launch. That list is why this module holds
|
//! saved layout to open, what to fill its parameters in with, and what to call
|
||||||
//! an arena: every string in it is owned here, and replacing the list frees the
|
//! the tab — one entry per tab, opened in order at launch. That list is why this
|
||||||
//! previous one in a single stroke rather than tracking each field.
|
//! 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
|
//! Because saving rewrites the whole file, there is exactly one `Settings` for
|
||||||
//! the process, reached through `get`. Two holders each convinced they knew
|
//! the process, reached through `get`. Two holders each convinced they knew
|
||||||
@@ -24,6 +31,8 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const glib = @import("glib");
|
const glib = @import("glib");
|
||||||
|
|
||||||
|
const palette = @import("palette.zig");
|
||||||
|
|
||||||
const Settings = @This();
|
const Settings = @This();
|
||||||
|
|
||||||
/// Bumped only if the on-disk shape changes incompatibly. Read but not yet
|
/// 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.
|
/// One of a layout's parameters, and what a startup tab opens it with.
|
||||||
pub const Value = struct {
|
pub const Value = struct {
|
||||||
name: []const u8,
|
name: []const u8,
|
||||||
@@ -95,6 +121,11 @@ arena: std.heap.ArenaAllocator,
|
|||||||
|
|
||||||
theme: Theme = .system,
|
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,
|
/// The tabs to open at launch, in order. Empty means a single plain shell,
|
||||||
/// which is what the app did before this setting existed.
|
/// which is what the app did before this setting existed.
|
||||||
startup: []const StartupTab = &.{},
|
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);
|
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.
|
/// Read the startup list.
|
||||||
///
|
///
|
||||||
/// Entries that make no sense are skipped rather than failing the file. The
|
/// 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.objectField("theme");
|
||||||
try json.write(@tagName(self.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
|
// Written even when empty, so that the key is discoverable in a file
|
||||||
// someone has opened to see what they can put in it.
|
// someone has opened to see what they can put in it.
|
||||||
try json.objectField("startup");
|
try json.objectField("startup");
|
||||||
@@ -416,3 +528,111 @@ fn configDir(buf: []u8) ?[:0]const u8 {
|
|||||||
const dir = std.mem.span(glib.getUserConfigDir());
|
const dir = std.mem.span(glib.getUserConfigDir());
|
||||||
return std.fmt.bufPrintZ(buf, "{s}/playpen", .{dir}) catch null;
|
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 gtk = @import("gtk");
|
||||||
|
|
||||||
const Layouts = @import("Layouts.zig");
|
const Layouts = @import("Layouts.zig");
|
||||||
|
const PaletteEditor = @import("PaletteEditor.zig");
|
||||||
const Settings = @import("Settings.zig");
|
const Settings = @import("Settings.zig");
|
||||||
const appearance = @import("appearance.zig");
|
const appearance = @import("appearance.zig");
|
||||||
|
|
||||||
@@ -95,6 +96,10 @@ ctx: ?*anyopaque,
|
|||||||
/// re-checked without asking each button what it stands for.
|
/// re-checked without asking each button what it stands for.
|
||||||
theme_buttons: [std.enums.values(Settings.Theme).len]*gtk.ToggleButton,
|
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
|
/// Container the startup rows live in, so rows can be added and removed after
|
||||||
/// the page is already on screen.
|
/// the page is already on screen.
|
||||||
startup_box: *gtk.Box,
|
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,
|
.on_capture = opts.on_capture,
|
||||||
.ctx = opts.ctx,
|
.ctx = opts.ctx,
|
||||||
.theme_buttons = undefined,
|
.theme_buttons = undefined,
|
||||||
|
.colors = undefined,
|
||||||
.startup_box = gtk.Box.new(.vertical, 6),
|
.startup_box = gtk.Box.new(.vertical, 6),
|
||||||
.error_label = gtk.Label.new(null),
|
.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.setTitle("Settings");
|
||||||
self.window.setTransientFor(parent);
|
self.window.setTransientFor(parent);
|
||||||
self.window.setModal(1);
|
self.window.setModal(1);
|
||||||
@@ -196,7 +208,7 @@ pub fn close() void {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Appearance
|
// 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 {
|
fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
|
||||||
const group = gtk.Box.new(.vertical, 10);
|
const group = gtk.Box.new(.vertical, 10);
|
||||||
group.as(gtk.Widget).addCssClass("playpen-settings-group");
|
group.as(gtk.Widget).addCssClass("playpen-settings-group");
|
||||||
@@ -227,10 +239,19 @@ fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
|
|||||||
row.append(self.buildThemeChoice());
|
row.append(self.buildThemeChoice());
|
||||||
|
|
||||||
group.append(row.as(gtk.Widget));
|
group.append(row.as(gtk.Widget));
|
||||||
|
group.append(self.colors.widget());
|
||||||
|
|
||||||
return group.as(gtk.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.
|
/// 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
|
/// 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]);
|
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);
|
for (self.startup.items) |row| self.freeRow(row);
|
||||||
self.startup.deinit(self.alloc);
|
self.startup.deinit(self.alloc);
|
||||||
|
self.colors.destroy();
|
||||||
self.alloc.destroy(self);
|
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,
|
// Background. The pane frame around us clips to its own rounded corners,
|
||||||
// so this just fills.
|
// so this just fills.
|
||||||
const default_bg: theme.Rgb = if (term.colors.background.get()) |c|
|
const default_bg: theme.Rgb = if (term.colors.background.get()) |c|
|
||||||
.from(c)
|
.fromVt(c)
|
||||||
else
|
else
|
||||||
theme.bg();
|
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|
|
const default_fg: theme.Rgb = if (term.colors.foreground.get()) |c|
|
||||||
.from(c)
|
.fromVt(c)
|
||||||
else
|
else
|
||||||
theme.fg();
|
theme.fg();
|
||||||
|
|
||||||
@@ -1133,7 +1133,7 @@ fn drawCursor(
|
|||||||
const x = pad + @as(f64, @floatFromInt(cursor.x)) * self.cell_w;
|
const x = pad + @as(f64, @floatFromInt(cursor.x)) * self.cell_w;
|
||||||
const y = pad + @as(f64, @floatFromInt(cursor.y)) * self.cell_h;
|
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();
|
const r, const g, const b = color.cairoRgb();
|
||||||
cr.setSourceRgb(r, g, b);
|
cr.setSourceRgb(r, g, b);
|
||||||
|
|
||||||
@@ -1191,7 +1191,7 @@ fn appearance(
|
|||||||
switch (cell.content_tag) {
|
switch (cell.content_tag) {
|
||||||
.bg_color_palette => return .{
|
.bg_color_palette => return .{
|
||||||
.fg = default_fg,
|
.fg = default_fg,
|
||||||
.bg = .from(palette[cell.content.color_palette.data]),
|
.bg = .fromVt(palette[cell.content.color_palette.data]),
|
||||||
.bold = false,
|
.bold = false,
|
||||||
.italic = false,
|
.italic = false,
|
||||||
.underline = false,
|
.underline = false,
|
||||||
@@ -1216,12 +1216,12 @@ fn appearance(
|
|||||||
var fg: theme.Rgb = switch (style.fg_color) {
|
var fg: theme.Rgb = switch (style.fg_color) {
|
||||||
.none => default_fg,
|
.none => default_fg,
|
||||||
.palette => |i| brightIfBold(palette, i, style.flags.bold),
|
.palette => |i| brightIfBold(palette, i, style.flags.bold),
|
||||||
.rgb => |c| .from(c),
|
.rgb => |c| .fromVt(c),
|
||||||
};
|
};
|
||||||
var bg: ?theme.Rgb = switch (style.bg_color) {
|
var bg: ?theme.Rgb = switch (style.bg_color) {
|
||||||
.none => null,
|
.none => null,
|
||||||
.palette => |i| .from(palette[i]),
|
.palette => |i| .fromVt(palette[i]),
|
||||||
.rgb => |c| .from(c),
|
.rgb => |c| .fromVt(c),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (style.flags.inverse) {
|
if (style.flags.inverse) {
|
||||||
@@ -1247,5 +1247,5 @@ fn appearance(
|
|||||||
/// with the matching bright color.
|
/// with the matching bright color.
|
||||||
fn brightIfBold(palette: *const [256]vt.color.RGB, index: u8, bold: bool) theme.Rgb {
|
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;
|
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
|
//! entries, scrollbars, dialog chrome. It is told to force light or dark, or
|
||||||
//! left on `default` so it follows the desktop.
|
//! left on `default` so it follows the desktop.
|
||||||
//! * **`style.css`** colours everything the app draws for itself. It is
|
//! * **`style.css`** colours everything the app draws for itself. It is
|
||||||
//! written entirely against named colours, with one palette file per scheme;
|
//! written entirely against named colours; `palette.zig` supplies their
|
||||||
//! the matching palette is prepended and the pair loaded as a single
|
//! values for the scheme in force, and the two are loaded as a single
|
||||||
//! provider.
|
//! provider.
|
||||||
//! * **`theme.zig`** holds the three fallback colours the terminal renderer
|
//! * **`theme.zig`** holds the colours the terminal renderer reads. Cairo never
|
||||||
//! reads. Cairo never consults the style tree, so a CSS reload on its own
|
//! consults the style tree, so a CSS reload on its own would leave every
|
||||||
//! would leave every terminal grid painted in the palette it started with.
|
//! terminal grid painted in the palette it started with.
|
||||||
//!
|
//!
|
||||||
//! The ordering that makes this work: the user's preference is only ever
|
//! 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.
|
//! *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
|
//! 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
|
//! that property means a desktop that changes scheme at sunset repaints this
|
||||||
//! app too, with no extra machinery and no second source of truth.
|
//! 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 std = @import("std");
|
||||||
const adw = @import("adw");
|
const adw = @import("adw");
|
||||||
@@ -26,14 +30,34 @@ const gobject = @import("gobject");
|
|||||||
const gtk = @import("gtk");
|
const gtk = @import("gtk");
|
||||||
|
|
||||||
const Settings = @import("Settings.zig");
|
const Settings = @import("Settings.zig");
|
||||||
|
const palette = @import("palette.zig");
|
||||||
const theme = @import("theme.zig");
|
const theme = @import("theme.zig");
|
||||||
|
|
||||||
/// The stylesheet, once per scheme. Concatenated at compile time so that a
|
/// The stylesheet, which is the same in both schemes: every colour in it is a
|
||||||
/// switch is a single `loadFromString` with no allocation and no file IO at
|
/// name, and the names are defined by the block `palette.zig` writes ahead of
|
||||||
/// the moment the desktop changes its mind.
|
/// it.
|
||||||
const style = @embedFile("style.css");
|
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
|
/// Called after the scheme changes, so the owner can repaint the things GTK
|
||||||
/// doesn't know are colour-dependent — the Cairo-drawn terminal grids.
|
/// doesn't know are colour-dependent — the Cairo-drawn terminal grids.
|
||||||
@@ -115,13 +139,27 @@ fn onDarkChanged(
|
|||||||
repaint();
|
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
|
/// Bring the stylesheet and the terminal palette to whatever libadwaita has
|
||||||
/// settled on. Idempotent, and cheap enough to call speculatively.
|
/// settled on. Idempotent, and cheap enough to call speculatively.
|
||||||
fn repaint() void {
|
fn repaint() void {
|
||||||
const dark = adw.StyleManager.getDefault().getDark() != 0;
|
const dark = adw.StyleManager.getDefault().getDark() != 0;
|
||||||
|
const scheme: palette.Scheme = if (dark) .dark else .light;
|
||||||
|
|
||||||
theme.setScheme(if (dark) .dark else .light);
|
// Before the stylesheet: both are built from the settings, and this is the
|
||||||
if (provider) |css| css.loadFromString(if (dark) css_dark else css_light);
|
// 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);
|
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
|
/* Zen-style vertical tabs: a sidebar column with the terminal inset to its
|
||||||
right.
|
right.
|
||||||
|
|
||||||
Every colour here is a name defined in `palette-dark.css` or
|
Every colour here is a name from `palette.zig`, which writes the matching
|
||||||
`palette-light.css`; `appearance.zig` prepends one of them and loads the
|
`@define-color` block for the scheme in force; `appearance.zig` prepends that
|
||||||
pair as a single provider. Nothing below may hardcode a colour — a literal
|
and loads the pair as a single provider. Nothing below may hardcode a colour —
|
||||||
hex would be a rule that looks right in one scheme and wrong in the other,
|
a literal hex would be a rule that looks right in one scheme and wrong in the
|
||||||
and it would look right in whichever one you happened to be testing in.
|
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
|
`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
|
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;
|
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
|
The startup list
|
||||||
|
|
||||||
|
|||||||
+62
-120
@@ -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
|
//! Everything the *widgets* are painted with reaches them as named CSS colours
|
||||||
//! (`Terminal.colors.palette`), which libghostty-vt initializes to the
|
//! (see `palette.zig` and `appearance.zig`). The terminal grid can't work that
|
||||||
//! standard xterm palette and keeps updated as programs change it via OSC.
|
//! 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
|
//! Those numbers are cached rather than resolved on demand. The renderer asks
|
||||||
//! running in it hasn't said otherwise — background, foreground and cursor —
|
//! for the default background once per frame and for the ANSI palette on every
|
||||||
//! and those have to flip with the rest of the window. They are kept here
|
//! cell, and neither wants to go through the settings to get an answer that only
|
||||||
//! rather than in the CSS because Cairo draws the terminal grid directly and
|
//! changes when the scheme or the palette does. `appearance.zig` is what tells
|
||||||
//! never consults the style tree; `appearance.zig` sets `scheme` at the same
|
//! us it has, and it does so before it reloads the stylesheet, so the frame the
|
||||||
//! moment it swaps the stylesheet, so the two stay in step.
|
//! two produce together is one palette rather than half of each.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
const vt = @import("ghostty-vt");
|
const vt = @import("ghostty-vt");
|
||||||
|
|
||||||
pub const Rgb = struct {
|
const Settings = @import("Settings.zig");
|
||||||
r: u8,
|
const palette = @import("palette.zig");
|
||||||
g: u8,
|
|
||||||
b: u8,
|
|
||||||
|
|
||||||
pub fn from(c: vt.color.RGB) Rgb {
|
pub const Rgb = palette.Rgb;
|
||||||
return .{ .r = c.r, .g = c.g, .b = c.b };
|
pub const Scheme = palette.Scheme;
|
||||||
}
|
|
||||||
|
|
||||||
/// Cairo takes color channels as 0..1 doubles.
|
/// The colours of one scheme, already resolved through the user's overrides.
|
||||||
pub fn cairoRgb(self: Rgb) struct { f64, f64, f64 } {
|
const Resolved = struct {
|
||||||
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 {
|
|
||||||
/// Terminal default background, used when the program hasn't set one.
|
/// Terminal default background, used when the program hasn't set one.
|
||||||
bg: Rgb,
|
bg: Rgb,
|
||||||
|
|
||||||
/// Terminal default foreground.
|
/// Terminal default foreground.
|
||||||
fg: Rgb,
|
fg: Rgb,
|
||||||
|
|
||||||
/// Cursor block color.
|
/// Cursor block colour.
|
||||||
cursor: Rgb,
|
cursor: Rgb,
|
||||||
|
|
||||||
/// Background and foreground of selected text. Both are fixed rather than
|
/// Background and foreground of selected text. Both are fixed rather than
|
||||||
/// derived from the cell underneath, because a selection has to read as
|
/// derived from the cell underneath, because a selection has to read as one
|
||||||
/// one continuous block across text the program has coloured every which
|
/// continuous block across text the program has coloured every which way —
|
||||||
/// way — a translucent wash over the existing colors leaves the highlight
|
/// a translucent wash over the existing colours leaves the highlight
|
||||||
/// looking like a rendering artefact wherever the text is already bright.
|
/// looking like a rendering artefact wherever the text is already bright.
|
||||||
selection_bg: Rgb,
|
selection_bg: Rgb,
|
||||||
selection_fg: Rgb,
|
selection_fg: Rgb,
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dark: Palette = .{
|
for (std.enums.values(palette.Key)) |key| {
|
||||||
.bg = .{ .r = 0x0e, .g = 0x16, .b = 0x24 },
|
const i = key.ansiIndex() orelse continue;
|
||||||
.fg = .{ .r = 0xdd, .g = 0xe6, .b = 0xf4 },
|
self.ansi[i] = palette.resolve(key, for_scheme, overrides).toVt();
|
||||||
.cursor = .{ .r = 0x61, .g = 0x91, .b = 0xf3 },
|
}
|
||||||
.selection_bg = .{ .r = 0x2f, .g = 0x52, .b = 0x96 },
|
|
||||||
.selection_fg = .{ .r = 0xdd, .g = 0xe6, .b = 0xf4 },
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Not a straight inversion. The cursor darkens rather than lightens, because
|
return self;
|
||||||
/// 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 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var scheme: Scheme = .dark;
|
var scheme: Scheme = .dark;
|
||||||
|
|
||||||
/// Switch the palette the terminal renderer draws with. Does not repaint
|
/// Resolved at compile time for the scheme the app opens in, so that the first
|
||||||
/// anything by itself; `appearance.zig` owns that.
|
/// 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 {
|
pub fn setScheme(to: Scheme) void {
|
||||||
scheme = to;
|
scheme = to;
|
||||||
|
current = .init(to, Settings.get().colors.of(to));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn currentScheme() Scheme {
|
pub fn currentScheme() Scheme {
|
||||||
return scheme;
|
return scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn palette() Palette {
|
|
||||||
return switch (scheme) {
|
|
||||||
.dark => dark,
|
|
||||||
.light => light,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bg() Rgb {
|
pub fn bg() Rgb {
|
||||||
return palette().bg;
|
return current.bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fg() Rgb {
|
pub fn fg() Rgb {
|
||||||
return palette().fg;
|
return current.fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor() Rgb {
|
pub fn cursor() Rgb {
|
||||||
return palette().cursor;
|
return current.cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn selectionBg() Rgb {
|
pub fn selectionBg() Rgb {
|
||||||
return palette().selection_bg;
|
return current.selection_bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn selectionFg() Rgb {
|
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.
|
/// The palette a session should treat as its default under the current scheme.
|
||||||
///
|
///
|
||||||
/// Handed to `DynamicPalette.changeDefault`, which is what makes this safe to
|
/// Handed to `DynamicPalette.changeDefault`, which is what makes this safe to
|
||||||
/// call on a terminal that has been running for hours: anything the program
|
/// 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
|
/// inside set with OSC 4 is preserved, and an OSC 104 reset later returns to the
|
||||||
/// the scheme's palette rather than to the one the app started in.
|
/// scheme's palette rather than to the one the app started in.
|
||||||
pub fn ansiPalette() vt.color.Palette {
|
pub fn ansiPalette() vt.color.Palette {
|
||||||
var colors = vt.color.default;
|
return current.ansi;
|
||||||
if (scheme == .light) {
|
|
||||||
for (light_ansi, 0..) |c, i| {
|
|
||||||
colors[i] = .{ .r = c.r, .g = c.g, .b = c.b };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return colors;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user