Add quick theming tool.
This commit is contained in:
+237
-16
@@ -18,8 +18,14 @@
|
||||
//! 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.
|
||||
//! The swatches start collapsed, and the two controls above them do not. That
|
||||
//! split is the point of the base colour: thirty-nine swatches is a fair way to
|
||||
//! *correct* a theme and a miserable way to *choose* one, so the path that is
|
||||
//! always on offer is "pick a colour, get a palette" (`tint.zig`), and the
|
||||
//! wall of swatches is behind a disclosure for the times that isn't enough. The
|
||||
//! two compose — a swatch that has been set by hand outranks the generated
|
||||
//! colour underneath it — which is why picking a new base does not clear the
|
||||
//! corrections someone made to the last one.
|
||||
|
||||
const std = @import("std");
|
||||
const gdk = @import("gdk");
|
||||
@@ -75,6 +81,17 @@ body: *gtk.Box,
|
||||
/// Says which scheme is being edited, and is rewritten when that changes.
|
||||
scheme_hint: *gtk.Label,
|
||||
|
||||
/// The colour the rest of the palette is generated from, the hex it currently
|
||||
/// stands at, and the button that drops it. Assigned in `build`, since the
|
||||
/// swatch has to be handed its dialog at construction.
|
||||
base: *gtk.ColorDialogButton,
|
||||
base_hex: *gtk.Label,
|
||||
base_clear: *gtk.Button,
|
||||
|
||||
/// How far apart to spread the colours built from the base. Insensitive until
|
||||
/// there is a base, since on its own it has nothing to spread.
|
||||
contrast: *gtk.Scale,
|
||||
|
||||
/// Puts the whole scheme back to its defaults.
|
||||
reset: *gtk.Button,
|
||||
|
||||
@@ -101,6 +118,12 @@ pub fn create(alloc: std.mem.Allocator, opts: Options) !*PaletteEditor {
|
||||
.root = gtk.Box.new(.vertical, 10),
|
||||
.body = gtk.Box.new(.vertical, 14),
|
||||
.scheme_hint = gtk.Label.new(null),
|
||||
.base = undefined,
|
||||
.base_hex = gtk.Label.new(null),
|
||||
.base_clear = gtk.Button.newFromIconName("edit-undo-symbolic"),
|
||||
// The step is what the arrow keys move by; the range is the one
|
||||
// `palette.Tint.contrast` is defined over.
|
||||
.contrast = gtk.Scale.newWithRange(.horizontal, -1, 1, 0.05),
|
||||
.reset = gtk.Button.newWithLabel("Reset every colour"),
|
||||
.rows = undefined,
|
||||
.groups = undefined,
|
||||
@@ -137,17 +160,24 @@ pub fn refresh(self: *PaletteEditor) void {
|
||||
fn build(self: *PaletteEditor) void {
|
||||
self.root.append(self.buildHeader());
|
||||
|
||||
// Above the base colour rather than inside the disclosure with the
|
||||
// swatches, because it is true of the base colour too: both palettes can be
|
||||
// given one, and this is the only thing on the page that says which of them
|
||||
// the control below is about to change.
|
||||
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.root.append(self.scheme_hint.as(gtk.Widget));
|
||||
|
||||
self.root.append(self.buildBase());
|
||||
|
||||
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",
|
||||
"Put every colour in this scheme back to the one Playpen ships with, " ++
|
||||
"and drop the base colour above",
|
||||
);
|
||||
_ = gtk.Button.signals.clicked.connect(self.reset, *PaletteEditor, &onReset, self, .{});
|
||||
self.body.append(self.reset.as(gtk.Widget));
|
||||
@@ -202,6 +232,118 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget {
|
||||
return row.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// The two controls that are always on offer: the colour the palette is built
|
||||
/// out of, and how far apart to spread what gets built.
|
||||
///
|
||||
/// Outside the disclosure below on purpose. This is the answer for someone who
|
||||
/// wants the app to be green, and the thirty-nine swatches are the answer for
|
||||
/// someone who has already got there and wants the "waiting on you" amber a
|
||||
/// shade warmer — putting the first behind the same toggle as the second would
|
||||
/// hide the easy path behind the hard one.
|
||||
fn buildBase(self: *PaletteEditor) *gtk.Widget {
|
||||
const group = gtk.Box.new(.vertical, 6);
|
||||
|
||||
// The same class the swatch sections below carry, which is where the
|
||||
// stylesheet hangs the "a colour button is a rectangle of the colour, not a
|
||||
// button holding one" rule. This row has a colour button in it and wants to
|
||||
// look like the ones under Customise.
|
||||
group.as(gtk.Widget).addCssClass("playpen-color-group");
|
||||
|
||||
// ---- the colour ----
|
||||
const row = gtk.Box.new(.horizontal, 8);
|
||||
row.as(gtk.Widget).addCssClass("playpen-color-row");
|
||||
|
||||
const label = gtk.Label.new("Base colour");
|
||||
label.setXalign(0);
|
||||
label.as(gtk.Widget).setHexpand(1);
|
||||
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
row.append(label.as(gtk.Widget));
|
||||
|
||||
self.base_hex.as(gtk.Widget).addCssClass("playpen-color-hex");
|
||||
row.append(self.base_hex.as(gtk.Widget));
|
||||
|
||||
const dialog = gtk.ColorDialog.new();
|
||||
dialog.setWithAlpha(0);
|
||||
dialog.setModal(1);
|
||||
dialog.setTitle("Base colour");
|
||||
|
||||
self.base = gtk.ColorDialogButton.new(dialog);
|
||||
self.base.as(gtk.Widget).setValign(.center);
|
||||
self.base.as(gtk.Widget).setSizeRequest(52, 24);
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
self.base,
|
||||
*PaletteEditor,
|
||||
&onBasePicked,
|
||||
self,
|
||||
.{ .detail = "rgba" },
|
||||
);
|
||||
row.append(self.base.as(gtk.Widget));
|
||||
|
||||
self.base_clear.as(gtk.Widget).addCssClass("flat");
|
||||
self.base_clear.as(gtk.Widget).setTooltipText("Back to Playpen's own colours");
|
||||
_ = gtk.Button.signals.clicked.connect(
|
||||
self.base_clear,
|
||||
*PaletteEditor,
|
||||
&onBaseCleared,
|
||||
self,
|
||||
.{},
|
||||
);
|
||||
row.append(self.base_clear.as(gtk.Widget));
|
||||
|
||||
group.append(row.as(gtk.Widget));
|
||||
group.append(hint(
|
||||
"Pick one and the rest follows: a very dark version of it behind the window, " ++
|
||||
"lighter ones for the panes and the text on them, and the colour itself as the accent. " ++
|
||||
"Anything you set under Customise stays where you put it.",
|
||||
));
|
||||
|
||||
// ---- how far apart ----
|
||||
const contrast_row = gtk.Box.new(.horizontal, 8);
|
||||
contrast_row.as(gtk.Widget).addCssClass("playpen-color-row");
|
||||
contrast_row.as(gtk.Widget).setTooltipText(
|
||||
"How far apart to spread the colours built from the base.\n" ++
|
||||
"Starker pulls the backdrop darker and the text brighter; softer draws them together.",
|
||||
);
|
||||
|
||||
const contrast_label = gtk.Label.new("Contrast");
|
||||
contrast_label.setXalign(0);
|
||||
contrast_label.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
contrast_row.append(contrast_label.as(gtk.Widget));
|
||||
|
||||
self.contrast.as(gtk.Widget).setHexpand(1);
|
||||
self.contrast.as(gtk.Widget).addCssClass("playpen-contrast");
|
||||
|
||||
// No fill behind the handle. GTK draws one from the low end of the range,
|
||||
// which would say the setting counts up from "soft"; it counts out from the
|
||||
// middle, and the mark there is what says so.
|
||||
self.contrast.setHasOrigin(0);
|
||||
|
||||
// No number beside it: the value is a position on a scale with no unit, and
|
||||
// "0.35" says less about what it will look like than the slider already does.
|
||||
self.contrast.setDrawValue(0);
|
||||
self.contrast.as(gtk.Range).setIncrements(0.05, 0.25);
|
||||
self.contrast.as(gtk.Range).setRoundDigits(2);
|
||||
|
||||
// The unlabelled middle mark is what makes the shipped spacing findable
|
||||
// again after a drag: GTK snaps the handle to a mark it passes near.
|
||||
self.contrast.addMark(-1, .bottom, "Soft");
|
||||
self.contrast.addMark(0, .bottom, null);
|
||||
self.contrast.addMark(1, .bottom, "Stark");
|
||||
|
||||
_ = gtk.Range.signals.value_changed.connect(
|
||||
self.contrast,
|
||||
*PaletteEditor,
|
||||
&onContrastChanged,
|
||||
self,
|
||||
.{},
|
||||
);
|
||||
contrast_row.append(self.contrast.as(gtk.Widget));
|
||||
|
||||
group.append(contrast_row.as(gtk.Widget));
|
||||
|
||||
return group.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 {
|
||||
@@ -352,7 +494,7 @@ fn reload(self: *PaletteEditor) void {
|
||||
defer self.updating = false;
|
||||
|
||||
const scheme = theme.currentScheme();
|
||||
const overrides = Settings.get().colors.of(scheme);
|
||||
const changes = Settings.get().colors.of(scheme);
|
||||
|
||||
var buf: [192]u8 = undefined;
|
||||
const heading: [:0]const u8 = std.fmt.bufPrintZ(
|
||||
@@ -363,10 +505,29 @@ fn reload(self: *PaletteEditor) void {
|
||||
) catch "Changes apply as you make them.";
|
||||
self.scheme_hint.setText(heading);
|
||||
|
||||
// With no base of its own, the swatch shows the accent this scheme is
|
||||
// already painted with — so the first click on it starts from the theme in
|
||||
// front of you rather than from whatever colour a picker opens on.
|
||||
const base = if (changes.tint) |tint|
|
||||
tint.base
|
||||
else
|
||||
palette.resolve(.accent, scheme, changes);
|
||||
|
||||
var base_rgba = toRgba(base);
|
||||
self.base.setRgba(&base_rgba);
|
||||
|
||||
var base_hex: [7:0]u8 = undefined;
|
||||
self.base_hex.setText(base.hex(&base_hex));
|
||||
self.base_clear.as(gtk.Widget).setSensitive(@intFromBool(changes.tint != null));
|
||||
|
||||
self.contrast.as(gtk.Range).setValue(if (changes.tint) |tint| tint.contrast else 0);
|
||||
self.contrast.as(gtk.Widget).setSensitive(@intFromBool(changes.tint != null));
|
||||
|
||||
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);
|
||||
// surface above it, or any colour at all under a base, has to show the
|
||||
// colour it is actually painted in.
|
||||
const color = palette.resolve(row.key, scheme, changes);
|
||||
|
||||
var rgba = toRgba(color);
|
||||
row.swatch.setRgba(&rgba);
|
||||
@@ -377,17 +538,20 @@ fn reload(self: *PaletteEditor) void {
|
||||
}
|
||||
|
||||
// Whether there is anything to revert *to*, which is a different
|
||||
// question: an inherited colour is not one that has been set.
|
||||
// question: neither an inherited colour nor a generated one is a colour
|
||||
// that has been set.
|
||||
if (row.revert) |button| {
|
||||
button.as(gtk.Widget).setSensitive(@intFromBool(overrides.get(row.key) != null));
|
||||
button.as(gtk.Widget).setSensitive(@intFromBool(changes.overrides.get(row.key) != null));
|
||||
}
|
||||
}
|
||||
|
||||
for (&self.groups) |*header| {
|
||||
header.reset.as(gtk.Widget).setSensitive(@intFromBool(groupIsSet(header.group, overrides)));
|
||||
header.reset.as(gtk.Widget).setSensitive(
|
||||
@intFromBool(groupIsSet(header.group, &changes.overrides)),
|
||||
);
|
||||
}
|
||||
|
||||
self.reset.as(gtk.Widget).setSensitive(@intFromBool(palette.anySet(overrides)));
|
||||
self.reset.as(gtk.Widget).setSensitive(@intFromBool(changes.anySet()));
|
||||
}
|
||||
|
||||
fn groupIsSet(group: palette.Group, overrides: *const palette.Overrides) bool {
|
||||
@@ -413,6 +577,19 @@ fn applied(self: *PaletteEditor) void {
|
||||
self.on_report(self.ctx, null);
|
||||
}
|
||||
|
||||
/// Show the change and leave the file for later.
|
||||
///
|
||||
/// For the contrast slider alone. Every other control here settles on a value
|
||||
/// once — a colour dialog reports when it is dismissed, a button when it is
|
||||
/// clicked — but a slider reports on every pixel of a drag, and writing the
|
||||
/// settings file at that rate would be an fsync per frame of an animation. The
|
||||
/// settings page writes on close, so nothing is lost by waiting; see the note at
|
||||
/// the top of `SettingsDialog`.
|
||||
fn appliedLive(self: *PaletteEditor) void {
|
||||
appearance.refresh();
|
||||
self.reload();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Handlers
|
||||
|
||||
@@ -434,33 +611,77 @@ fn onColorPicked(
|
||||
// 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()));
|
||||
Settings.get().colors.of(scheme).overrides.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);
|
||||
Settings.get().colors.of(theme.currentScheme()).overrides.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());
|
||||
const changes = Settings.get().colors.of(theme.currentScheme());
|
||||
|
||||
for (std.enums.values(palette.Key)) |key| {
|
||||
if (key.group() == header.group) overrides.set(key, null);
|
||||
if (key.group() == header.group) changes.overrides.set(key, null);
|
||||
}
|
||||
|
||||
self.applied();
|
||||
}
|
||||
|
||||
fn onReset(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void {
|
||||
Settings.get().colors.of(theme.currentScheme()).* = palette.no_overrides;
|
||||
Settings.get().colors.of(theme.currentScheme()).* = .{};
|
||||
self.applied();
|
||||
}
|
||||
|
||||
fn onBasePicked(
|
||||
swatch: *gtk.ColorDialogButton,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *PaletteEditor,
|
||||
) callconv(.c) void {
|
||||
if (self.updating) return;
|
||||
|
||||
// The scheme is read here rather than remembered, for the reason
|
||||
// `onColorPicked` gives.
|
||||
const changes = Settings.get().colors.of(theme.currentScheme());
|
||||
changes.tint = .{
|
||||
.base = fromRgba(swatch.getRgba()),
|
||||
// Carried over rather than reset: trying a second base against a spread
|
||||
// you have already settled on is the common move, and having the slider
|
||||
// jump back to the middle every time would make it impossible.
|
||||
.contrast = if (changes.tint) |tint| tint.contrast else 0,
|
||||
};
|
||||
|
||||
self.applied();
|
||||
}
|
||||
|
||||
fn onBaseCleared(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void {
|
||||
// Only the generated palette goes. Colours set by hand were set against
|
||||
// what was underneath them and are still what their owner asked for, so
|
||||
// dropping those too is `onReset`'s job and is labelled as such.
|
||||
Settings.get().colors.of(theme.currentScheme()).tint = null;
|
||||
self.applied();
|
||||
}
|
||||
|
||||
fn onContrastChanged(scale: *gtk.Scale, self: *PaletteEditor) callconv(.c) void {
|
||||
if (self.updating) return;
|
||||
|
||||
const changes = Settings.get().colors.of(theme.currentScheme());
|
||||
|
||||
// The slider is insensitive without a base, so this is unreachable in the
|
||||
// normal way — but the property can still be set from a screen reader or a
|
||||
// theme change landing mid-drag, and a contrast with nothing to spread is
|
||||
// not worth a repaint.
|
||||
if (changes.tint) |*tint| {
|
||||
tint.contrast = @floatCast(scale.as(gtk.Range).getValue());
|
||||
self.appliedLive();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
|
||||
|
||||
Reference in New Issue
Block a user