//! The color 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 //! color here paints something behind this window, so a change is its own //! preview — pick a sidebar color and the sidebar is that color 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 color 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 colors go one step further: left alone they *follow* //! the surface and text colors above them (see `palette.Key.inherits`), so //! recoloring the app recolors the terminal drawn on it without asking anyone //! to set the same color twice — which is also why every row is re-read after //! any change, not just the row that changed. //! //! The swatches start collapsed, and the two controls above them do not. That //! split is the point of the base color: 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 color, 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 //! color 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"); 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 color: 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 grayed 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, /// The color 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 colors 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, /// Indexed by `@intFromEnum(key)`, so a row is found without searching. Inline /// in the struct rather than allocated: there is exactly one row per color, 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), .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 color"), .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()); // Above the base color rather than inside the disclosure with the // swatches, because it is true of the base color 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.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 color in this scheme back to the one Playpen ships with, " ++ "and drop the base color above", ); _ = 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 color 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("Customize"); _ = 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); } /// The two controls that are always on offer: the color 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 color button is a rectangle of the color, not a // button holding one" rule. This row has a color button in it and wants to // look like the ones under Customize. group.as(gtk.Widget).addCssClass("playpen-color-group"); // ---- the color ---- const row = gtk.Box.new(.horizontal, 8); row.as(gtk.Widget).addCssClass("playpen-color-row"); const label = gtk.Label.new("Base color"); 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 color"); 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 colors"); _ = 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 color itself as the accent. " ++ "Anything you set under Customize 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 colors 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 unlabeled 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 { var current: ?palette.Group = null; var section: *gtk.Box = undefined; // The ANSI colors are a grid of bare swatches rather than sixteen labeled // rows: they are a palette people recognize 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 colors 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 color 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 color 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 /// color 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 honor. 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 changes = 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); // 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 color 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 color, not the override: a terminal color that is following the // surface above it, or any color at all under a base, has to show the // color it is actually painted in. const color = palette.resolve(row.key, scheme, changes); 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: neither an inherited color nor a generated one is a color // that has been set. if (row.revert) |button| { 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, &changes.overrides)), ); } self.reset.as(gtk.Widget).setSensitive(@intFromBool(changes.anySet())); } 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 color 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); } /// Show the change and leave the file for later. /// /// For the contrast slider alone. Every other control here settles on a value /// once — a color 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 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 color in the palette that isn't // on screen. const scheme = theme.currentScheme(); 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()).overrides.set(row.key, null); self.applied(); } fn onResetGroup(_: *gtk.Button, header: *GroupHeader) callconv(.c) void { const self = header.editor; const changes = Settings.get().colors.of(theme.currentScheme()); for (std.enums.values(palette.Key)) |key| { 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()).* = .{}; 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. Colors 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 labeled 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 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 color 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 /// color 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)); }