Add notifications.

This commit is contained in:
Greyson Parrelli committed 2026-08-26 23:24:14 -04:00
1 parent afad6a2636
commit 7414c89fe6
33 files changed
+1026 -381

No files matched your search

+69 -27
View File
@@ -4,8 +4,8 @@
//! something changes. It carries a version from the start, so adding the next
//! setting is not also the day we invent a format.
//!
//! The settings are three different shapes. The colour scheme is one word. The
//! **palette changes** are a base colour per scheme, plus a colour per name for
//! The settings are three different shapes. The color scheme is one word. The
//! **palette changes** are a base color per scheme, plus a color per name for
//! the names someone has gone on to change by hand. The **startup tabs** are a
//! list: which
//! saved layout to open, what to fill its parameters in with, and what to call
@@ -15,9 +15,9 @@
//! field.
//!
//! The palette changes deliberately stay out of that arena. They are a base
//! colour and a fixed-size array of optionals, which needs no allocation at
//! color and 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
//! point, `setStartup` replaces the arena wholesale, so anything color-shaped
//! living in it would be freed by an unrelated edit to the startup list. The
//! **shortcut overrides** are the same shape for the same reason: a chord is a
//! key and four flags once it has been parsed, so nothing about it needs to
@@ -73,8 +73,8 @@ pub const Theme = enum {
}
};
/// What someone has done to the palette, per scheme: the colour it is generated
/// from, and the individual colours they have set by hand.
/// What someone has done to the palette, per scheme: the color it is generated
/// from, and the individual colors they have set by hand.
///
/// 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
@@ -137,8 +137,17 @@ theme: Theme = .system,
/// a mistyped chord and every shell in every tab exiting.
confirm_quit: bool = true,
/// 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
/// Whether a pane finishing posts a desktop notification.
///
/// On by default. The whole shape of this app is "set something going in a tab
/// and go and do something else", and a sidebar dot can only tell you that
/// while you are looking at the sidebar. Someone who does not want to be told
/// has a switch in the settings and a per-tab mute in every row menu; someone
/// who does want to be told should not have to find a setting first.
notifications: bool = true,
/// Changes to the palette. Empty means every color is at its default, which is
/// the state anyone who never opens the color editor stays in — and it is the
/// reason the defaults can be retuned in a later version and still reach them.
colors: Colors = .{},
@@ -183,7 +192,7 @@ pub fn deinit() void {
/// Read the settings file, falling back to defaults for anything missing.
///
/// Every failure below lands on the same behaviour — carry on with defaults —
/// Every failure below lands on the same behavior — carry on with defaults —
/// because there is no useful alternative: this runs before there is a window
/// to report an error in, and refusing to start over an unreadable preferences
/// file would be a worse outcome than ignoring it. A malformed file is logged
@@ -259,6 +268,10 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
if (value == .bool) self.confirm_quit = value.bool;
}
if (root.get("notifications")) |value| {
if (value == .bool) self.notifications = value.bool;
}
if (root.get("colors")) |value| self.parseColors(value);
if (root.get("tint")) |value| self.parseTint(value);
@@ -270,8 +283,8 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
/// 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 —
/// Nothing here can fail the file. A color is one word of a palette, and the
/// answer to a word we don't recognize 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 {
@@ -298,7 +311,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
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.*});
std.log.warn("settings: no color called \"{s}\"; ignoring it", .{kv.key_ptr.*});
continue;
};
@@ -313,7 +326,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
};
const color = palette.Rgb.parse(text) orelse {
std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text});
std.log.warn("settings: \"{s}\" is not a #rrggbb color", .{text});
continue;
};
@@ -325,7 +338,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
/// Read the generated palettes: `{"dark": {"base": "#3a76f0", "contrast": 0.5}}`.
///
/// A scheme without a `base` has no generated palette at all, whatever else the
/// entry says — the contrast is how far apart to spread the colours built from
/// entry says — the contrast is how far apart to spread the colors built from
/// the base, so on its own it describes nothing. That is also why a malformed
/// contrast costs only the contrast: the base is the setting, and dropping the
/// whole theme over one bad number would be the wrong trade.
@@ -356,7 +369,7 @@ fn parseTint(self: *Settings, raw: std.json.Value) void {
};
const base = palette.Rgb.parse(text) orelse {
std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text});
std.log.warn("settings: \"{s}\" is not a #rrggbb color", .{text});
continue;
};
@@ -386,7 +399,7 @@ fn parseTint(self: *Settings, raw: std.json.Value) void {
/// list — or `null` — is how a shortcut is turned off, which is worth having:
/// the chords here are ones a program running in the terminal may want back.
///
/// Nothing here can fail the file, on the same grounds as the colours above. An
/// Nothing here can fail the file, on the same grounds as the colors above. An
/// action this build doesn't have, or a chord that doesn't parse, costs that one
/// binding and is logged; what it falls back to is the shipped default, which is
/// exactly what an absent entry already means.
@@ -610,8 +623,10 @@ fn serialize(self: *Settings) SaveError![]u8 {
try json.write(@tagName(self.theme));
try json.objectField("confirm_quit");
try json.write(self.confirm_quit);
try json.objectField("notifications");
try json.write(self.notifications);
// Only the colours that have been changed, and only the schemes that have
// Only the colors 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.
@@ -636,7 +651,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
}
try json.endObject();
// And the base colour each scheme's palette is generated from, for the
// And the base color each scheme's palette is generated from, for the
// schemes that have one. Written after the overrides rather than before
// because that is the order they apply in, and a settings file is read by
// people as well as by this.
@@ -657,7 +672,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
try json.endObject();
// Only the shortcuts that have been rebound, for the same reasons as the
// colours — and with the same consequence, which is the one that matters
// colors — and with the same consequence, which is the one that matters
// here: saving rewrites the whole file, so a hand-edited shortcut has to
// come back out of this function or changing the theme would delete it.
// Chords are written from the parsed form rather than the text they came
@@ -738,7 +753,7 @@ fn configDir(buf: []u8) ?[:0]const u8 {
//
// 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
// allocator. The palette overrides are what these are here for — a color 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.
@@ -768,6 +783,33 @@ test "confirming a quit is on unless the file turns it off" {
try std.testing.expect(std.mem.indexOf(u8, text, "\"confirm_quit\": false") != null);
}
test "notifications are on unless the file turns them off" {
var settings = forTesting();
defer settings.arena.deinit();
try std.testing.expect(settings.notifications);
try settings.parse(
\\{"notifications": false}
);
try std.testing.expect(!settings.notifications);
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"notifications\": false") != null);
// And unreadable is the same as absent, which for this flag means on: the
// failure someone would report is "it stopped telling me", not "it told me
// and I had typed nonsense into the file".
var typo = forTesting();
defer typo.arena.deinit();
try typo.parse(
\\{"notifications": "off"}
);
try std.testing.expect(typo.notifications);
}
test "a confirm_quit we can't read leaves the confirmation up" {
var settings = forTesting();
defer settings.arena.deinit();
@@ -797,19 +839,19 @@ test "palette overrides are read per scheme" {
try std.testing.expectEqual(palette.Rgb.parse("#112233").?, settings.colors.dark.overrides.get(.ansi_red).?);
try std.testing.expectEqual(palette.Rgb.parse("#fafafa").?, settings.colors.light.overrides.get(.sidebar).?);
// A scheme that says nothing about a colour leaves it at its default, which
// A scheme that says nothing about a color 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.overrides.get(.accent));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
}
test "a colour we can't read costs that colour and nothing else" {
test "a color we can't read costs that color and nothing else" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"theme": "dark", "colors": {"dark": {
\\ "not_a_colour": "#123456",
\\ "not_a_color": "#123456",
\\ "border": "rebeccapurple",
\\ "sidebar": 12,
\\ "accent": "#ff8800"
@@ -819,8 +861,8 @@ test "a colour we can't read costs that colour and nothing else" {
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.border));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
// The whole file survives, including the theme above the colours and the
// one colour in the list that was well formed.
// The whole file survives, including the theme above the colors and the
// one color 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.overrides.get(.accent).?);
}
@@ -835,7 +877,7 @@ test "colors is written even when nothing has been changed" {
try std.testing.expect(std.mem.indexOf(u8, text, "\"colors\": {}") != null);
}
test "only the changed colours are written, and they come back" {
test "only the changed colors are written, and they come back" {
var settings = forTesting();
defer settings.arena.deinit();
@@ -864,7 +906,7 @@ test "only the changed colours are written, and they come back" {
}
}
test "a base colour and its contrast survive the file" {
test "a base color and its contrast survive the file" {
var settings = forTesting();
defer settings.arena.deinit();