Add theme customizer.

This commit is contained in:
Greyson Parrelli
2026-08-13 15:55:23 -04:00
parent 2daeb6125f
commit 695417a601
12 changed files with 1661 additions and 244 deletions
+498
View File
@@ -0,0 +1,498 @@
//! The colour editor: one swatch per name in the palette, for the scheme that
//! is on screen.
//!
//! It edits **the palette you can see**, and that is the whole design. Every
//! colour here paints something behind this window, so a change is its own
//! preview — pick a sidebar colour and the sidebar is that colour before the
//! dialog has closed. Editing the *other* scheme's palette from here would mean
//! a page full of swatches whose effect you have to take on faith, so the
//! answer to "I want to change the light one" is the theme picker above: switch
//! to light, and this section switches with it.
//!
//! Overrides are stored per colour rather than as a whole palette, which is what
//! lets a row say whether it has been changed and put itself back, and what lets
//! the defaults be retuned in a later version for everyone who never touched
//! them. The terminal's colours go one step further: left alone they *follow*
//! the surface and text colours above them (see `palette.Key.inherits`), so
//! recolouring the app recolours the terminal drawn on it without asking anyone
//! to set the same colour twice — which is also why every row is re-read after
//! any change, not just the row that changed.
//!
//! The section starts collapsed. It is thirty-nine swatches, and a settings page
//! that opens on all of them buries the two settings most people came for.
const std = @import("std");
const gdk = @import("gdk");
const gobject = @import("gobject");
const gtk = @import("gtk");
const Settings = @import("Settings.zig");
const appearance = @import("appearance.zig");
const palette = @import("palette.zig");
const theme = @import("theme.zig");
const PaletteEditor = @This();
/// How the editor reports a settings file it couldn't write. The settings page
/// owns the label it goes in, since the startup list below reports the same
/// failure the same way.
pub const Report = *const fn (ctx: ?*anyopaque, message: ?[:0]const u8) void;
pub const Options = struct {
on_report: Report,
ctx: ?*anyopaque = null,
};
/// One colour: its swatch, and — for the ones listed as rows rather than packed
/// into the ANSI grid — the hex it currently resolves to and the button that
/// puts it back.
const Row = struct {
editor: *PaletteEditor,
key: palette.Key,
swatch: *gtk.ColorDialogButton,
hex: ?*gtk.Label = null,
revert: ?*gtk.Button = null,
};
/// One section heading, kept only so its reset button can be greyed out when
/// the section has nothing to reset.
const GroupHeader = struct {
editor: *PaletteEditor,
group: palette.Group,
reset: *gtk.Button,
};
const group_count = std.enums.values(palette.Group).len;
alloc: std.mem.Allocator,
/// The whole section: the disclosure row, and the swatches under it.
root: *gtk.Box,
/// Everything the toggle shows and hides.
body: *gtk.Box,
/// Says which scheme is being edited, and is rewritten when that changes.
scheme_hint: *gtk.Label,
/// Puts the whole scheme back to its defaults.
reset: *gtk.Button,
/// Indexed by `@intFromEnum(key)`, so a row is found without searching. Inline
/// in the struct rather than allocated: there is exactly one row per colour, the
/// count is known at compile time, and the addresses have to be stable because
/// every swatch's callback holds one.
rows: [palette.count]Row,
groups: [group_count]GroupHeader,
/// Set while the rows are being written from the settings, so that the
/// `notify::rgba` each `setRgba` provokes doesn't come back in as an edit.
updating: bool = false,
on_report: Report,
ctx: ?*anyopaque,
pub fn create(alloc: std.mem.Allocator, opts: Options) !*PaletteEditor {
const self = try alloc.create(PaletteEditor);
errdefer alloc.destroy(self);
self.* = .{
.alloc = alloc,
.root = gtk.Box.new(.vertical, 10),
.body = gtk.Box.new(.vertical, 14),
.scheme_hint = gtk.Label.new(null),
.reset = gtk.Button.newWithLabel("Reset every colour"),
.rows = undefined,
.groups = undefined,
.on_report = opts.on_report,
.ctx = opts.ctx,
};
self.build();
self.reload();
return self;
}
/// Free the editor's own memory. The widgets belong to the window that is being
/// torn down around them, so there is nothing to destroy here.
pub fn destroy(self: *PaletteEditor) void {
self.alloc.destroy(self);
}
pub fn widget(self: *PaletteEditor) *gtk.Widget {
return self.root.as(gtk.Widget);
}
/// Re-read every swatch from the settings.
///
/// Called by the settings page when the theme changes, because the scheme being
/// edited changed with it — the same rows now stand for the other palette.
pub fn refresh(self: *PaletteEditor) void {
self.reload();
}
// -------------------------------------------------------------------------
// Building
fn build(self: *PaletteEditor) void {
self.root.append(self.buildHeader());
self.scheme_hint.setXalign(0);
self.scheme_hint.setWrap(1);
self.scheme_hint.as(gtk.Widget).addCssClass("playpen-dialog-hint");
self.body.append(self.scheme_hint.as(gtk.Widget));
self.buildGroups();
self.reset.as(gtk.Widget).addCssClass("flat");
self.reset.as(gtk.Widget).setHalign(.start);
self.reset.as(gtk.Widget).setTooltipText(
"Put every colour in this scheme back to the one Playpen ships with",
);
_ = gtk.Button.signals.clicked.connect(self.reset, *PaletteEditor, &onReset, self, .{});
self.body.append(self.reset.as(gtk.Widget));
// Collapsed until asked for; see the note at the top of the file.
self.body.as(gtk.Widget).setVisible(0);
self.root.append(self.body.as(gtk.Widget));
}
/// The disclosure row, in the same shape as the theme row above it: what it is
/// on the left, the control on the right.
fn buildHeader(self: *PaletteEditor) *gtk.Widget {
const row = gtk.Box.new(.horizontal, 12);
const labels = gtk.Box.new(.vertical, 2);
labels.as(gtk.Widget).setHexpand(1);
labels.as(gtk.Widget).setValign(.center);
const name = gtk.Label.new("Palette");
name.setXalign(0);
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
labels.append(name.as(gtk.Widget));
const sub = gtk.Label.new("Every colour the window and the terminal are painted from.");
sub.setXalign(0);
sub.setWrap(1);
sub.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
labels.append(sub.as(gtk.Widget));
row.append(labels.as(gtk.Widget));
// Wrapped rather than styled directly: the settings page's button styling
// hangs off the container, so this comes out looking like one of the theme
// buttons beside it rather than like a stock Adwaita button.
const wrap = gtk.Box.new(.horizontal, 0);
wrap.as(gtk.Widget).addCssClass("linked");
wrap.as(gtk.Widget).addCssClass("playpen-settings-choice");
wrap.as(gtk.Widget).setValign(.center);
const toggle = gtk.ToggleButton.newWithLabel("Customise");
_ = gtk.ToggleButton.signals.toggled.connect(
toggle,
*PaletteEditor,
&onDisclosureToggled,
self,
.{},
);
wrap.append(toggle.as(gtk.Widget));
row.append(wrap.as(gtk.Widget));
return row.as(gtk.Widget);
}
/// One section per group, in the order the keys are declared: the surfaces, the
/// text on them, the accents, and the terminal last.
fn buildGroups(self: *PaletteEditor) void {
var current: ?palette.Group = null;
var section: *gtk.Box = undefined;
// The ANSI colours are a grid of bare swatches rather than sixteen labelled
// rows: they are a palette people recognise by position — eight normal, then
// eight bright — and a list of their names says less than the strip does.
var ansi: *gtk.Grid = undefined;
for (std.enums.values(palette.Key)) |key| {
const group = key.group();
if (current == null or current.? != group) {
current = group;
section = gtk.Box.new(.vertical, 6);
section.as(gtk.Widget).addCssClass("playpen-color-group");
section.append(self.buildGroupHeader(group));
if (group.hint()) |text| section.append(hint(text));
if (group == .ansi) {
ansi = gtk.Grid.new();
ansi.setRowSpacing(4);
ansi.setColumnSpacing(4);
ansi.as(gtk.Widget).setHalign(.start);
section.append(ansi.as(gtk.Widget));
}
self.body.append(section.as(gtk.Widget));
}
if (key.ansiIndex()) |i| {
const swatch = self.newSwatch(key, 30, 22);
ansi.attach(swatch.as(gtk.Widget), @intCast(i % 8), @intCast(i / 8), 1, 1);
} else {
section.append(self.buildRow(key));
}
}
}
fn buildGroupHeader(self: *PaletteEditor, group: palette.Group) *gtk.Widget {
const row = gtk.Box.new(.horizontal, 6);
const title = gtk.Label.new(group.label());
title.setXalign(0);
title.as(gtk.Widget).setHexpand(1);
title.as(gtk.Widget).addCssClass("playpen-color-group-title");
row.append(title.as(gtk.Widget));
const reset = gtk.Button.newFromIconName("edit-undo-symbolic");
reset.as(gtk.Widget).addCssClass("flat");
reset.as(gtk.Widget).setTooltipText("Reset the colours in this section");
const header = &self.groups[@intFromEnum(group)];
header.* = .{ .editor = self, .group = group, .reset = reset };
_ = gtk.Button.signals.clicked.connect(reset, *GroupHeader, &onResetGroup, header, .{});
row.append(reset.as(gtk.Widget));
return row.as(gtk.Widget);
}
/// One colour as a row: what it is, what it currently is, and the two controls
/// for changing that.
fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget {
const row = gtk.Box.new(.horizontal, 8);
row.as(gtk.Widget).addCssClass("playpen-color-row");
const label = gtk.Label.new(key.label());
label.setXalign(0);
label.as(gtk.Widget).setHexpand(1);
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
row.append(label.as(gtk.Widget));
// The tooltip carries what the colour is *for*, plus the name it goes by in
// the settings file, since anyone hand-editing that file is looking at this
// list to find out what to call things.
var tip: [256]u8 = undefined;
const tooltip: [:0]const u8 = std.fmt.bufPrintZ(&tip, "{s}{s}{s}", .{
key.hint() orelse "",
if (key.hint() != null) "\n" else "",
@tagName(key),
}) catch @tagName(key);
row.as(gtk.Widget).setTooltipText(tooltip);
const hex = gtk.Label.new(null);
hex.as(gtk.Widget).addCssClass("playpen-color-hex");
row.append(hex.as(gtk.Widget));
const swatch = self.newSwatch(key, 52, 24);
row.append(swatch.as(gtk.Widget));
const revert = gtk.Button.newFromIconName("edit-undo-symbolic");
revert.as(gtk.Widget).addCssClass("flat");
revert.as(gtk.Widget).setTooltipText("Back to the default");
const entry = &self.rows[@intFromEnum(key)];
entry.hex = hex;
entry.revert = revert;
_ = gtk.Button.signals.clicked.connect(revert, *Row, &onRevert, entry, .{});
row.append(revert.as(gtk.Widget));
return row.as(gtk.Widget);
}
/// A swatch, and the row record that goes with it.
///
/// Alpha is off: `style.css` derives every wash it needs from `alpha()` on the
/// colour it belongs to, so a translucent palette entry would be a second way
/// of saying the same thing, and one the terminal renderer could not honour.
fn newSwatch(
self: *PaletteEditor,
key: palette.Key,
width: c_int,
height: c_int,
) *gtk.ColorDialogButton {
const dialog = gtk.ColorDialog.new();
dialog.setWithAlpha(0);
dialog.setModal(1);
dialog.setTitle(key.label());
const swatch = gtk.ColorDialogButton.new(dialog);
swatch.as(gtk.Widget).setValign(.center);
swatch.as(gtk.Widget).setSizeRequest(width, height);
swatch.as(gtk.Widget).setTooltipText(key.label());
const entry = &self.rows[@intFromEnum(key)];
entry.* = .{ .editor = self, .key = key, .swatch = swatch };
_ = gobject.Object.signals.notify.connect(
swatch,
*Row,
&onColorPicked,
entry,
.{ .detail = "rgba" },
);
return swatch;
}
// -------------------------------------------------------------------------
// Reading the settings back into the rows
fn reload(self: *PaletteEditor) void {
self.updating = true;
defer self.updating = false;
const scheme = theme.currentScheme();
const overrides = Settings.get().colors.of(scheme);
var buf: [192]u8 = undefined;
const heading: [:0]const u8 = std.fmt.bufPrintZ(
&buf,
"Editing the {s} palette, the one on screen — changes apply as you make them. " ++
"Switch the theme above to edit the other.",
.{scheme.label()},
) catch "Changes apply as you make them.";
self.scheme_hint.setText(heading);
for (&self.rows) |*row| {
// The colour, not the override: a terminal colour that is following the
// surface above it has to show the colour it is actually painted in.
const color = palette.resolve(row.key, scheme, overrides);
var rgba = toRgba(color);
row.swatch.setRgba(&rgba);
if (row.hex) |label| {
var hex_buf: [7:0]u8 = undefined;
label.setText(color.hex(&hex_buf));
}
// Whether there is anything to revert *to*, which is a different
// question: an inherited colour is not one that has been set.
if (row.revert) |button| {
button.as(gtk.Widget).setSensitive(@intFromBool(overrides.get(row.key) != null));
}
}
for (&self.groups) |*header| {
header.reset.as(gtk.Widget).setSensitive(@intFromBool(groupIsSet(header.group, overrides)));
}
self.reset.as(gtk.Widget).setSensitive(@intFromBool(palette.anySet(overrides)));
}
fn groupIsSet(group: palette.Group, overrides: *const palette.Overrides) bool {
for (std.enums.values(palette.Key)) |key| {
if (key.group() == group and overrides.get(key) != null) return true;
}
return false;
}
/// Show the change, then write it down.
///
/// In that order on purpose: repainting is what the user asked for and cannot
/// fail, while the write can — and a settings file that couldn't be written is
/// worth saying so about without also refusing the colour for this session.
fn applied(self: *PaletteEditor) void {
appearance.refresh();
self.reload();
Settings.get().save() catch {
self.on_report(self.ctx, "Could not write the settings file.");
return;
};
self.on_report(self.ctx, null);
}
// -------------------------------------------------------------------------
// Handlers
fn onDisclosureToggled(button: *gtk.ToggleButton, self: *PaletteEditor) callconv(.c) void {
self.body.as(gtk.Widget).setVisible(button.getActive());
}
fn onColorPicked(
swatch: *gtk.ColorDialogButton,
_: *gobject.ParamSpec,
row: *Row,
) callconv(.c) void {
const self = row.editor;
if (self.updating) return;
// The scheme is read here rather than remembered from the last reload: a
// desktop that flips to light under a `system` preference while this page is
// open changes which palette an edit belongs to, and writing to the one the
// rows were last filled from would put the colour in the palette that isn't
// on screen.
const scheme = theme.currentScheme();
Settings.get().colors.of(scheme).set(row.key, fromRgba(swatch.getRgba()));
self.applied();
}
fn onRevert(_: *gtk.Button, row: *Row) callconv(.c) void {
const self = row.editor;
Settings.get().colors.of(theme.currentScheme()).set(row.key, null);
self.applied();
}
fn onResetGroup(_: *gtk.Button, header: *GroupHeader) callconv(.c) void {
const self = header.editor;
const overrides = Settings.get().colors.of(theme.currentScheme());
for (std.enums.values(palette.Key)) |key| {
if (key.group() == header.group) overrides.set(key, null);
}
self.applied();
}
fn onReset(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void {
Settings.get().colors.of(theme.currentScheme()).* = palette.no_overrides;
self.applied();
}
// -------------------------------------------------------------------------
// Small helpers
fn hint(text: [:0]const u8) *gtk.Widget {
const label = gtk.Label.new(text);
label.setXalign(0);
label.setWrap(1);
label.as(gtk.Widget).addCssClass("playpen-dialog-hint");
return label.as(gtk.Widget);
}
fn toRgba(color: palette.Rgb) gdk.RGBA {
return .{
.f_red = @as(f32, @floatFromInt(color.r)) / 255.0,
.f_green = @as(f32, @floatFromInt(color.g)) / 255.0,
.f_blue = @as(f32, @floatFromInt(color.b)) / 255.0,
.f_alpha = 1.0,
};
}
/// GTK works in floats and the palette works in bytes, so a colour that came
/// out of the picker's own HSV wheel or its eyedropper has to be rounded to
/// something a hex triplet can hold. Rounding rather than truncating, so that a
/// colour typed in as hex comes back as the same hex.
fn fromRgba(rgba: *const gdk.RGBA) palette.Rgb {
return .{
.r = channel(rgba.f_red),
.g = channel(rgba.f_green),
.b = channel(rgba.f_blue),
};
}
fn channel(value: f32) u8 {
return @intFromFloat(@round(std.math.clamp(value, 0.0, 1.0) * 255.0));
}