From 7414c89fe66091cde4cd682be6f8c3afc3d03505 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 26 Aug 2026 23:24:14 -0400 Subject: [PATCH] Add notifications. --- .claude/skills/leave-review/SKILL.md | 4 +- README.md | 119 +++++++--- build.zig | 19 +- dist/dev.greyson.playpen.svg | 4 +- src/Layout.zig | 2 +- src/Layouts.zig | 2 +- src/OpenLayoutDialog.zig | 2 +- src/PaletteEditor.zig | 108 ++++----- src/Pane.zig | 20 +- src/PathEntry.zig | 4 +- src/SaveLayoutDialog.zig | 2 +- src/Session.zig | 4 +- src/Settings.zig | 96 +++++--- src/SettingsDialog.zig | 80 ++++++- src/Terminal.zig | 2 +- src/View.zig | 28 ++- src/Window.zig | 148 ++++++++++-- src/appearance.zig | 20 +- src/emoji.zig | 2 +- src/notify.zig | 336 +++++++++++++++++++++++++++ src/oklab.zig | 40 ++-- src/palette.zig | 106 ++++----- src/review/Store.zig | 2 +- src/style.css | 83 ++++--- src/theme.zig | 18 +- src/tint.zig | 104 ++++----- tools/gen-emoji.py | 6 +- web/public/favicon.svg | 2 +- web/src/App.tsx | 10 +- web/src/components/CommentThread.tsx | 2 +- web/src/components/DiffView.tsx | 10 +- web/src/components/Icon.tsx | 4 +- web/src/styles.css | 18 +- 33 files changed, 1026 insertions(+), 381 deletions(-) create mode 100644 src/notify.zig diff --git a/.claude/skills/leave-review/SKILL.md b/.claude/skills/leave-review/SKILL.md index a1b846b..f02d659 100644 --- a/.claude/skills/leave-review/SKILL.md +++ b/.claude/skills/leave-review/SKILL.md @@ -119,10 +119,10 @@ didn't touch, unless the change made it newly wrong. In rough priority order: leaves state half-updated, panics on input the caller controls. 3. **Resources and lifetimes** — leaks, missing close/cancel, work that outlives what it belongs to. -4. **Concurrency** — data races, state mutated without the lock its neighbours +4. **Concurrency** — data races, state mutated without the lock its neighbors take, deadlock ordering. 5. **Interface and contract** — a caller that can now be silently wrong; a - behaviour change not reflected in the doc comment right above it. + behavior change not reflected in the doc comment right above it. 6. **Tests** — a new branch with real failure modes and no test; a test that would pass with the bug still in. 7. **Fit** — code that ignores an existing helper, layering, or naming pattern diff --git a/README.md b/README.md index aa85f2d..ffe3fa5 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ just the VT core. ``` main.zig AdwApplication startup -appearance.zig the colour scheme: preference -> libadwaita, CSS, palette +appearance.zig the color scheme: preference -> libadwaita, CSS, palette Window.zig sidebar + GtkStack of views, tab management, reordering, shortcuts View.zig one tab's content: its panes, their layout, and drag handling Layout.zig the split tree: nodes, rearranging, GtkPaned materialization @@ -141,11 +141,12 @@ Session.zig libghostty-vt Terminal + parser, fed by the PTY Pty.zig openpt/fork/exec, controlling terminal setup script.zig runs a command for `$(...)` in a layout's directory key.zig GDK keyval -> libghostty-vt key mapping -palette.zig every colour by name, its default per scheme, and the CSS for it +notify.zig desktop notifications on a finish, and the mutes that stop them +palette.zig every color by name, its default per scheme, and the CSS for it 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 -PaletteEditor.zig the colour editor inside it: a swatch per palette entry +PaletteEditor.zig the color editor inside it: a swatch per palette entry TabSettingsDialog.zig one tab's own settings, and the emoji picker emoji.zig generated: every emoji the picker offers, and the search over them ``` @@ -182,7 +183,7 @@ Running terminals keep their scrollback and processes straight through a move. **Drags rearrange live.** Each time the drop target changes, the move is applied for real, so the layout under the cursor is always the layout you will get — there is no separate drop indicator because the view itself is the -preview. Cancelling a drag puts the pane back: only the dragged pane ever +preview. Canceling a drag puts the pane back: only the dragged pane ever moves, so the rest of the tree is unchanged and re-inserting it beside its original sibling restores the original shape. @@ -225,7 +226,7 @@ save it, and replace the literal paths with `{{parameters}}`. name, parameters and per-pane scripts can be changed without opening it. Renaming moves the layout rather than copying it, and renaming onto a name another layout already has is refused instead of quietly replacing it. Nothing -is written until you confirm, so cancelling leaves the layout untouched. To +is written until you confirm, so canceling leaves the layout untouched. To change the *shape* of a layout, open it, rearrange the tab, and save over it under the same name — the same tools you used to build it in the first place. @@ -269,7 +270,7 @@ means `string`: { "name": "path", "description": "Project directory", "type": "directory" } ``` -A type this build doesn't recognise is asked for in a text box rather than +A type this build doesn't recognize is asked for in a text box rather than refusing the file — unlike a pane `kind`, which is a pane it cannot build. The startup list asks for its values the same way, so a directory parameter gets the same field there. @@ -451,7 +452,7 @@ to notice you are about to close the wrong window. **Keep Working** is the default response, and Escape lands on it too: the guard should not itself be a keystroke to fumble. Pressing the compositor's close -binding again while the question is up dismisses it, which counts as cancelling +binding again while the question is up dismisses it, which counts as canceling — leaning on the key never costs the window. Two closes don't ask, because both are already an answer: accepting the dialog, @@ -511,6 +512,11 @@ to closing the moment the window manager says so: - **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether it is working, waiting on you, or finished and still unanswered. See [Agent status](#agent-status) +- **A desktop notification when a pane finishes**, for the tabs you are not + looking at — on by default, switchable from **Settings → Notifications**, and + mutable one tab at a time from its right-click menu, for fifteen minutes, an + hour, eight hours, or until you say otherwise. See + [Notifications](#notifications) - **Renaming a tab**: `Ctrl+Shift+R`, double-click a tab row, or **Rename** in its right-click menu. A typed name pins the label; clearing it hands the label back to the panes @@ -531,11 +537,11 @@ to closing the moment the window manager says so: - **Light and dark schemes**, following the desktop by default and pinnable from the settings page, applying to open tabs immediately — terminal palette 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) +- **Every color in the palette editable**, per scheme, from **Settings → + Appearance → Customize**: the surfaces, the text on them, the accents, the + status colors, and the terminal's own background, cursor, selection and 16 + ANSI colors. Each change repaints as you make it. See + [Customizing the palette](#customizing-the-palette) ### Shortcuts @@ -725,7 +731,7 @@ table, and add to its `SYNONYMS` when a glyph should answer to a word the data files don't know. Two things it leaves out. Skin-tone variants, because just over half of Unicode's 3944 sequences are the same gesture in five tones and a grid of them is longer to look through rather than more complete — every base glyph is -there. And anything newer than emoji 16.0, because colour fonts trail Unicode by +there. And anything newer than emoji 16.0, because color fonts trail Unicode by a year or two and a glyph the font has never heard of draws as a hex-digit box, which in a picker reads as a bug; `--max-version` raises the cutoff once fonts have caught up. @@ -923,11 +929,11 @@ So a tab can carry a dot: | red | stopped on an error | | none | idle | -Each colour is carried at three sizes, so it survives being glanced at rather +Each color is carried at three sizes, so it survives being glanced at rather than read: an 11px dot on the row, a bar down the row's leading edge, and a wash behind the whole row. Working gets the bar without the wash — it is the resting state of a busy afternoon, and tinting half the sidebar all day would only teach -you to stop looking. Inside a tab, a pane that is asking colours its own frame +you to stop looking. Inside a tab, a pane that is asking colors its own frame and header the same way, which is what picks it out of a four-way split. The green one is the point of the feature. A session that has gone back to idle @@ -959,6 +965,48 @@ so once the row has brought you to the tab, they say which pane inside it was the one asking; the row stays lit until every finished pane in it has been answered. +### Notifications + +The dots answer "which of these needs me?" for a window that is in front of you. +When it isn't — behind an editor, on another workspace, or on the other monitor — +a pane finishing also posts a **desktop notification**, titled with the tab's +name and carrying that pane's title as its body. That title is the useful half: +the hook sets it from your prompt, so what the popup says is the task that just +finished rather than "playpen". + +It is on by default and **Settings → Notifications** switches it off, which +leaves the sidebar dots as the only word you get: + +```json +{ + "notifications": false +} +``` + +Nothing is posted for a tab you were already looking at — the window has the +focus and that tab is the one on screen — because the dot has already said it +and a popup about the pane in front of you is the fastest way to make someone +switch the whole feature off. Coming back to a tab also takes down whatever +popup it left in the tray, the same visit that clears its row. + +**One tab at a time can be muted** from its right-click menu, for 15 minutes, an +hour, 8 hours, or until you unmute it. That is the answer for the one session +that finishes every ninety seconds while the other three are worth hearing +about; reopening the menu says how long the mute has left and offers the way out +of it. A mute is session state and is not written to the settings file — a tab +does not outlive the app, and neither should a decision about the next hour. +Neither mute touches the dots. Muting is about being interrupted; the row is +still how you find out what happened while you were away. + +The notification is sent as a `GNotification`, so GIO picks whichever backend +the session has and the popup gets Playpen's name and icon from the installed +desktop entry — which means it looks right once `mise run install` has put that +entry in place. Nothing is attached to a click: the app is deliberately +non-unique, so no process owns `dev.greyson.playpen` on the session bus and a +daemon calling an action back would find nobody home. Clicking closes the popup; +getting back to the window is the compositor's job, and it already has a binding +for that. + ### How it gets there Programs report state with **OSC 9;4** — the ConEmu progress protocol, the same @@ -1049,8 +1097,8 @@ 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 **system**, which is the default and follows the desktop. -Every colour it resolves to can be changed — see -[Customising the palette](#customising-the-palette). +Every color it resolves to can be changed — see +[Customizing the palette](#customizing-the-palette). The preference lives in `~/.config/playpen/settings.json` (or `$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the @@ -1061,6 +1109,7 @@ The preference lives in `~/.config/playpen/settings.json` (or "version": 1, "theme": "system", "confirm_quit": true, + "notifications": true, "colors": {}, "startup": [] } @@ -1069,20 +1118,20 @@ The preference lives in `~/.config/playpen/settings.json` (or Switching applies immediately to every open tab — nothing needs restarting, and a shell that has been running all day repaints along with everything else. -Three separate colour systems have to agree for that to be true, which is what +Three separate color systems have to agree for that to be true, which is what `appearance.zig` exists to arrange: -- **libadwaita's style manager** colours the stock widgets — popovers, entries, +- **libadwaita's style manager** colors the stock widgets — popovers, entries, dialog chrome. It is told to force a scheme, or left on `default` to follow the desktop. -- **`style.css`** colours everything the app draws itself. It is written - entirely against named colours; `palette.zig` writes the `@define-color` +- **`style.css`** colors everything the app draws itself. It is written + entirely against named colors; `palette.zig` writes the `@define-color` block that defines them for the scheme in force, and the two are loaded as a - single `GtkCssProvider`. No rule in `style.css` may hardcode a colour — a + single `GtkCssProvider`. No rule in `style.css` may hardcode a color — a literal hex is a rule that looks right in whichever scheme you happened to be - testing in, and one the colour editor cannot reach. + testing in, and one the color editor cannot reach. - **`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 colors. The style tree is never consulted there, so a CSS reload alone would leave every terminal 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 @@ -1092,7 +1141,7 @@ Three separate colour systems have to agree for that to be true, which is what 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 invisible — and prompts and build tools use it constantly, so a light scheme -without a light palette is a light scheme you can't read. The 240 colours above +without a light palette is a light scheme you can't read. The 240 colors above index 16 are fixed by spec and left alone; only the 16 named ones change. They are swapped through libghostty-vt's `DynamicPalette.changeDefault`, which @@ -1106,20 +1155,20 @@ 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 this app with it, with no extra machinery and no second source of truth. -### Customising the palette +### Customizing the palette -**Settings → Appearance → Customise** opens the palette itself: a swatch for -every colour named above, in sections — surfaces, text, tab rows, accent, +**Settings → Appearance → Customize** opens the palette itself: a swatch for +every color 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 +and the 16 ANSI colors 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 +and a button that puts that one color 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 +- **A change applies as you make it.** Every color 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 @@ -1128,9 +1177,9 @@ the editor edits **the palette you can see**: 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, + muted accent — so recoloring the app recolors 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. + those explicitly breaks the link for that color and only that color. Only what has been changed is stored, per scheme, under `colors` in `settings.json`: @@ -1150,12 +1199,12 @@ 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 +`#rrggbb`, costs that one color 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 +colors 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. diff --git a/build.zig b/build.zig index 556cd21..a580e22 100644 --- a/build.zig +++ b/build.zig @@ -150,7 +150,7 @@ pub fn build(b: *std.Build) void { paths_tests.root_module.addImport("glib", gobject.module("glib2")); test_step.dependOn(&b.addRunArtifact(paths_tests).step); - // The palette is the third root: a table of colours, the rules for + // The palette is the third root: a table of colors, 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. @@ -225,6 +225,23 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(store_tests).step); + // The mute rules are their own root. Whether a tab is silent at this instant, + // and what its row menu says about that, is arithmetic on a deadline — and + // it is the half of notifications that can be wrong without anyone noticing, + // since the symptom is a popup that didn't arrive. It reaches gio to hand a + // notification to the session, which is the part these tests leave alone. + const notify_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/notify.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + notify_tests.root_module.addImport("gio", gobject.module("gio2")); + notify_tests.root_module.addImport("glib", gobject.module("glib2")); + test_step.dependOn(&b.addRunArtifact(notify_tests).step); + const emoji_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/emoji.zig"), diff --git a/dist/dev.greyson.playpen.svg b/dist/dev.greyson.playpen.svg index 296f6a5..27d89fc 100644 --- a/dist/dev.greyson.playpen.svg +++ b/dist/dev.greyson.playpen.svg @@ -4,12 +4,12 @@ edge. Shapes are kept large and high-contrast so the sidebar is still readable when the icon is scaled down to 32px in a launcher. - Colours track the app's dark palette — the navy surfaces of + Colors track the app's dark palette — the navy surfaces of `palette-dark.css` and Signal's ultramarine as the accent. An icon is the one part of the app that is looked at next to a row of other icons rather than on its own, so it stays on the dark scheme in both: a launcher shelf where one icon repaints itself with the desktop theme is a worse result than - one that is simply recognisable. + one that is simply recognizable. --> diff --git a/src/Layout.zig b/src/Layout.zig index 85f51fb..228619c 100644 --- a/src/Layout.zig +++ b/src/Layout.zig @@ -315,7 +315,7 @@ fn assemble(node: *Node) void { } /// The side `node` sits on within its parent split, and the parent's ratio. -/// Used to put a pane back where it started when a drag is cancelled. +/// Used to put a pane back where it started when a drag is canceled. pub fn positionOf(node: *Node) ?struct { sibling: *Node, side: Side, ratio: f64 } { const parent = node.parent orelse return null; const s = parent.kind.split; diff --git a/src/Layouts.zig b/src/Layouts.zig index bf6c3d0..34cdb09 100644 --- a/src/Layouts.zig +++ b/src/Layouts.zig @@ -303,7 +303,7 @@ fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node { /// A parameter's type, defaulting to a plain string. /// -/// An unrecognised type is a string rather than a parse error, which is the +/// An unrecognized type is a string rather than a parse error, which is the /// opposite of how a leaf's `kind` is treated — and deliberately. A `kind` this /// build has never heard of is a pane it cannot build; a *type* it has never /// heard of is only a prompt it cannot improve on, and asking for it in a text diff --git a/src/OpenLayoutDialog.zig b/src/OpenLayoutDialog.zig index 338e4e0..65ce30a 100644 --- a/src/OpenLayoutDialog.zig +++ b/src/OpenLayoutDialog.zig @@ -78,7 +78,7 @@ pub fn present( for (layout.parameters, 0..) |param, i| { const label = gtk.Label.new(null); // The description is what the author wrote for a human; the bare name - // is the fallback so a parameter is never unlabelled. + // is the fallback so a parameter is never unlabeled. var label_buf: [128]u8 = undefined; const text = if (param.description.len > 0) param.description else param.name; label.setText(std.fmt.bufPrintZ(&label_buf, "{s}", .{text}) catch "parameter"); diff --git a/src/PaletteEditor.zig b/src/PaletteEditor.zig index 8ec24e1..47922cd 100644 --- a/src/PaletteEditor.zig +++ b/src/PaletteEditor.zig @@ -1,30 +1,30 @@ -//! The colour editor: one swatch per name in the palette, for the scheme that +//! 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 -//! 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 +//! 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 colour rather than as a whole palette, which is what +//! 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 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 +//! 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 colour: thirty-nine swatches is a fair way to +//! 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 colour, get a palette" (`tint.zig`), and the +//! 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 -//! colour underneath it — which is why picking a new base does not clear the +//! 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"); @@ -49,7 +49,7 @@ pub const Options = struct { ctx: ?*anyopaque = null, }; -/// One colour: its swatch, and — for the ones listed as rows rather than packed +/// 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 { @@ -60,7 +60,7 @@ const Row = struct { revert: ?*gtk.Button = null, }; -/// One section heading, kept only so its reset button can be greyed out when +/// 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, @@ -81,14 +81,14 @@ 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 +/// 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 colours built from the base. Insensitive until +/// 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, @@ -96,7 +96,7 @@ contrast: *gtk.Scale, 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 +/// 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, @@ -124,7 +124,7 @@ pub fn create(alloc: std.mem.Allocator, opts: Options) !*PaletteEditor { // 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"), + .reset = gtk.Button.newWithLabel("Reset every color"), .rows = undefined, .groups = undefined, .on_report = opts.on_report, @@ -160,8 +160,8 @@ 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 + // 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); @@ -176,8 +176,8 @@ fn build(self: *PaletteEditor) void { 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, " ++ - "and drop the base colour above", + "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)); @@ -201,7 +201,7 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget { 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."); + 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"); @@ -217,7 +217,7 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget { wrap.as(gtk.Widget).addCssClass("playpen-settings-choice"); wrap.as(gtk.Widget).setValign(.center); - const toggle = gtk.ToggleButton.newWithLabel("Customise"); + const toggle = gtk.ToggleButton.newWithLabel("Customize"); _ = gtk.ToggleButton.signals.toggled.connect( toggle, *PaletteEditor, @@ -232,7 +232,7 @@ 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 +/// 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 @@ -244,16 +244,16 @@ 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. + // 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 colour ---- + // ---- the color ---- const row = gtk.Box.new(.horizontal, 8); row.as(gtk.Widget).addCssClass("playpen-color-row"); - const label = gtk.Label.new("Base colour"); + const label = gtk.Label.new("Base color"); label.setXalign(0); label.as(gtk.Widget).setHexpand(1); label.as(gtk.Widget).addCssClass("playpen-dialog-label"); @@ -265,7 +265,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget { const dialog = gtk.ColorDialog.new(); dialog.setWithAlpha(0); dialog.setModal(1); - dialog.setTitle("Base colour"); + dialog.setTitle("Base color"); self.base = gtk.ColorDialogButton.new(dialog); self.base.as(gtk.Widget).setValign(.center); @@ -280,7 +280,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget { 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"); + self.base_clear.as(gtk.Widget).setTooltipText("Back to Playpen's own colors"); _ = gtk.Button.signals.clicked.connect( self.base_clear, *PaletteEditor, @@ -293,15 +293,15 @@ fn buildBase(self: *PaletteEditor) *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.", + "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 colours built from the base.\n" ++ + "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.", ); @@ -324,7 +324,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget { 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 + // 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); @@ -350,8 +350,8 @@ 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 + // 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; @@ -396,7 +396,7 @@ fn buildGroupHeader(self: *PaletteEditor, group: palette.Group) *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"); + reset.as(gtk.Widget).setTooltipText("Reset the colors in this section"); const header = &self.groups[@intFromEnum(group)]; header.* = .{ .editor = self, .group = group, .reset = reset }; @@ -407,7 +407,7 @@ fn buildGroupHeader(self: *PaletteEditor, group: palette.Group) *gtk.Widget { return row.as(gtk.Widget); } -/// One colour as a row: what it is, what it currently is, and the two controls +/// 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); @@ -419,7 +419,7 @@ fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget { 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 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; @@ -454,8 +454,8 @@ fn buildRow(self: *PaletteEditor, key: palette.Key) *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. +/// 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, @@ -507,7 +507,7 @@ fn reload(self: *PaletteEditor) void { // 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. + // front of you rather than from whatever color a picker opens on. const base = if (changes.tint) |tint| tint.base else @@ -524,9 +524,9 @@ fn reload(self: *PaletteEditor) void { 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, or any colour at all under a base, has to show the - // colour it is actually painted in. + // 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); @@ -538,7 +538,7 @@ fn reload(self: *PaletteEditor) void { } // Whether there is anything to revert *to*, which is a different - // question: neither an inherited colour nor a generated one is a colour + // 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)); @@ -565,7 +565,7 @@ fn groupIsSet(group: palette.Group, overrides: *const palette.Overrides) bool { /// /// 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. +/// worth saying so about without also refusing the color for this session. fn applied(self: *PaletteEditor) void { appearance.refresh(); self.reload(); @@ -580,7 +580,7 @@ fn applied(self: *PaletteEditor) void { /// 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 +/// 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 @@ -608,7 +608,7 @@ fn onColorPicked( // 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 + // 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())); @@ -660,9 +660,9 @@ fn onBasePicked( } fn onBaseCleared(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void { - // Only the generated palette goes. Colours set by hand were set against + // 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 labelled as such. + // dropping those too is `onReset`'s job and is labeled as such. Settings.get().colors.of(theme.currentScheme()).tint = null; self.applied(); } @@ -702,10 +702,10 @@ fn toRgba(color: palette.Rgb) gdk.RGBA { }; } -/// GTK works in floats and the palette works in bytes, so a colour that came +/// 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 -/// colour typed in as hex comes back as the same hex. +/// 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), diff --git a/src/Pane.zig b/src/Pane.zig index 5245492..a42e923 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -136,7 +136,7 @@ const all_sides = [_]Side{ .left, .right, .top, .bottom }; // Status dots // // Both the pane header and the sidebar row show the same small filled circle, -// recoloured by CSS. Symbolic icons take their colour from the `color` +// recolored by CSS. Symbolic icons take their color from the `color` // property, so the state is carried entirely by which class is applied and // there is no second icon to keep in sync. @@ -159,8 +159,8 @@ pub const status_classes = [_][:0]const u8{ /// Point a dot at one of `status_classes`, or pass null to hide it. /// -/// Hiding rather than clearing the colour matters: an idle pane should show -/// nothing at all, not a dot in the background colour that still takes up +/// Hiding rather than clearing the color matters: an idle pane should show +/// nothing at all, not a dot in the background color that still takes up /// space and still catches the eye when the row is hovered. pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void { const w = dot.as(gtk.Widget); @@ -174,7 +174,7 @@ pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void { } } -/// What a dot is signalling: the four states content can report, plus one it +/// What a dot is signaling: the four states content can report, plus one it /// cannot know about on its own. /// /// `done` is the whole reason this is a separate type from `Status`. Content @@ -212,7 +212,7 @@ pub const Attention = enum { /// carrying it — a sidebar row, or a pane's frame. /// /// Returned as a pair so the two can never drift into disagreeing about - /// what colour a state is. Null means idle, which paints nothing at all. + /// what color a state is. Null means idle, which paints nothing at all. pub fn classes(self: Attention) ?struct { dot: [:0]const u8, surface: [:0]const u8, @@ -255,8 +255,8 @@ pub const attention_classes = [_][:0]const u8{ "playpen-attn-failed", }; -/// Bring a dot in line with what it should be signalling. The tooltip comes -/// along with the colour, since a colour on its own doesn't say what it means. +/// Bring a dot in line with what it should be signaling. The tooltip comes +/// along with the color, since a color on its own doesn't say what it means. pub fn applyDot(dot: *gtk.Image, state: Attention) void { setDot(dot, state.class()); dot.as(gtk.Widget).setTooltipText(state.tooltip()); @@ -267,7 +267,7 @@ pub fn applyDot(dot: *gtk.Image, state: Attention) void { /// A dot alone is a few pixels, which is fine once you know where to look and /// no use at all for the thing this is for: sweeping a sidebar and picking out /// the row that wants you. The surface class is what lets the row and the pane -/// frame carry the same colour at a size you can catch out of the corner of +/// frame carry the same color at a size you can catch out of the corner of /// your eye. pub fn applyAttention(surface: *gtk.Widget, dot: *gtk.Image, state: Attention) void { applyDot(dot, state); @@ -435,7 +435,7 @@ pub fn titleSlice(self: *const Pane) [:0]const u8 { return std.mem.sliceTo(&self.title, 0); } -/// What this pane's dot is signalling right now. +/// What this pane's dot is signaling right now. pub fn attention(self: *const Pane) Attention { return .of(self.status, self.done_unanswered); } @@ -608,7 +608,7 @@ fn installDropTarget(self: *Pane) void { /// Work out where a drop at this point would put the dragged pane. /// /// The side is whichever edge of this pane the pointer is nearest, so every -/// point maps to some edge and there is no dead centre. If that point is also +/// point maps to some edge and there is no dead center. If that point is also /// close to the matching border of the view itself, the drop is promoted to /// span the whole layout instead of splitting just this pane — dropping along /// the bottom of the window gives a pane stretching across the bottom, while diff --git a/src/PathEntry.zig b/src/PathEntry.zig index fc3c6f0..c528859 100644 --- a/src/PathEntry.zig +++ b/src/PathEntry.zig @@ -12,7 +12,7 @@ //! the matches agree, the arrow keys pick out of the list, and `Enter` takes //! the highlighted one or, with nothing highlighted, means what it means //! everywhere else in the dialog. `GtkEntryCompletion` would have been the -//! stock answer and is deprecated as of GTK 4.10, and its inline behaviour was +//! stock answer and is deprecated as of GTK 4.10, and its inline behavior was //! never this: it completes to the *first* match rather than to the longest //! common prefix, which in a directory of siblings guesses wrong more often //! than it helps. @@ -455,7 +455,7 @@ fn onFolderChosen( var err: ?*glib.Error = null; const file = dialog.selectFolderFinish(result, &err) orelse { // Dismissing the chooser is how most trips through it end, and - // cancelling it is how a field that has gone away ends one, so neither + // canceling it is how a field that has gone away ends one, so neither // is worth a word. if (err) |e| e.free(); return; diff --git a/src/SaveLayoutDialog.zig b/src/SaveLayoutDialog.zig index bdbebb8..cd6112e 100644 --- a/src/SaveLayoutDialog.zig +++ b/src/SaveLayoutDialog.zig @@ -11,7 +11,7 @@ //! //! The tree is allocated in the layout store's arena in both cases, so saving //! writes the edited strings straight back into its leaves. Nothing is written -//! until the confirm button, so cancelling an edit leaves the layout alone. +//! until the confirm button, so canceling an edit leaves the layout alone. const std = @import("std"); const gio = @import("gio"); diff --git a/src/Session.zig b/src/Session.zig index 9aef349..08a9cde 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -317,7 +317,7 @@ fn EffectReply(comptime name: []const u8) type { } /// Effect callback: the child asked what kind of terminal this is (CSI c and -/// friends). The defaults describe a VT220 that does ANSI colour, which is +/// friends). The defaults describe a VT220 that does ANSI color, which is /// what this actually is — the answer matters less than there being one. fn effectDeviceAttributes( _: *vt.TerminalStream.Handler, @@ -326,7 +326,7 @@ fn effectDeviceAttributes( } /// Effect callback: the child asked whether it is drawing on a light or dark -/// background (CSI ? 996 n), so it can pick readable colours. Answered from +/// background (CSI ? 996 n), so it can pick readable colors. Answered from /// the scheme the grid is actually painted in. fn effectColorScheme( _: *vt.TerminalStream.Handler, diff --git a/src/Settings.zig b/src/Settings.zig index 777a804..a10ed8a 100644 --- a/src/Settings.zig +++ b/src/Settings.zig @@ -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(); diff --git a/src/SettingsDialog.zig b/src/SettingsDialog.zig index 9b21af4..ebd42ec 100644 --- a/src/SettingsDialog.zig +++ b/src/SettingsDialog.zig @@ -109,6 +109,9 @@ colors: *PaletteEditor, /// one place and read in another; nothing re-checks it. confirm_quit: *gtk.Switch, +/// The notifications toggle, held for the same reason. +notifications: *gtk.Switch, + /// Container the startup rows live in, so rows can be added and removed after /// the page is already on screen. startup_box: *gtk.Box, @@ -145,6 +148,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo .theme_buttons = undefined, .colors = undefined, .confirm_quit = gtk.Switch.new(), + .notifications = gtk.Switch.new(), .startup_box = gtk.Box.new(.vertical, 6), .error_label = gtk.Label.new(null), }; @@ -165,6 +169,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo content.as(gtk.Widget).addCssClass("playpen-dialog-content"); content.append(self.buildAppearance()); + content.append(self.buildNotifications()); content.append(self.buildQuitting()); content.append(self.buildStartup()); @@ -219,7 +224,7 @@ pub fn close() void { // ------------------------------------------------------------------------- // Appearance -/// The Appearance section: the colour scheme, and the palette it resolves to. +/// The Appearance section: the color scheme, and the palette it resolves to. fn buildAppearance(self: *SettingsDialog) *gtk.Widget { const group = gtk.Box.new(.vertical, 10); group.as(gtk.Widget).addCssClass("playpen-settings-group"); @@ -266,7 +271,7 @@ fn onEditorReport(ctx: ?*anyopaque, message: ?[:0]const u8) void { /// 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 -/// clicking the active one, which is the behaviour we want, but the group also +/// clicking the active one, which is the behavior we want, but the group also /// emits `toggled` twice per change — once off, once on — and the off half /// would apply whichever scheme happened to be next in the list. Handling the /// exclusivity here instead makes each click exactly one decision. @@ -326,13 +331,82 @@ fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c) 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. + // just changed: its swatches now stand for the other palette's colors. self.colors.refresh(); } // ------------------------------------------------------------------------- // Quitting +/// The Notifications section: whether a finish reaches the desktop. +/// +/// The whole-app half of the mute. The per-tab half lives in each row's menu, +/// because it is a thing you decide about one tab while looking at it, and +/// walking to a settings window to say "not this one, for the next hour" would +/// mean nobody ever did. +fn buildNotifications(self: *SettingsDialog) *gtk.Widget { + const group = gtk.Box.new(.vertical, 10); + group.as(gtk.Widget).addCssClass("playpen-settings-group"); + + const title = gtk.Label.new("Notifications"); + title.setXalign(0); + title.as(gtk.Widget).addCssClass("playpen-settings-title"); + group.append(title.as(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("Tell me when a pane finishes"); + name.setXalign(0); + name.as(gtk.Widget).addCssClass("playpen-dialog-label"); + labels.append(name.as(gtk.Widget)); + + const hint_label = gtk.Label.new( + "A desktop notification when something running in a tab goes quiet, " ++ + "for the tabs you aren't looking at. Off, the sidebar dots are the " ++ + "only word you get. One tab at a time can be muted from its row menu.", + ); + hint_label.setXalign(0); + hint_label.setWrap(1); + hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); + labels.append(hint_label.as(gtk.Widget)); + + row.append(labels.as(gtk.Widget)); + + self.notifications.setActive(@intFromBool(Settings.get().notifications)); + self.notifications.as(gtk.Widget).setValign(.center); + _ = gobject.Object.signals.notify.connect( + self.notifications, + *SettingsDialog, + &onNotificationsChanged, + self, + .{ .detail = "active" }, + ); + row.append(self.notifications.as(gtk.Widget)); + + group.append(row.as(gtk.Widget)); + + return group.as(gtk.Widget); +} + +/// Straight to the file, for the same reason `onConfirmQuitChanged` is. +fn onNotificationsChanged( + toggle: *gtk.Switch, + _: *gobject.ParamSpec, + self: *SettingsDialog, +) callconv(.c) void { + Settings.get().notifications = toggle.getActive() != 0; + + Settings.get().save() catch { + self.showError("Could not write the settings file."); + return; + }; + self.showError(null); +} + /// The Quitting section: whether closing the window asks first. /// /// Above the startup list rather than below it. That list grows with the number diff --git a/src/Terminal.zig b/src/Terminal.zig index 867ee87..3cc174d 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -712,7 +712,7 @@ fn selectPress(self: *Terminal, x: f64, y: f64) void { const term = &self.session.term; const pin = self.pinAt(x, y) orelse return; - // The gesture counts the clicks and picks the behaviour: one selects by + // The gesture counts the clicks and picks the behavior: one selects by // cell, two by word, three by line. const sel = self.gesture.press(term, .{ .time = now(), diff --git a/src/View.zig b/src/View.zig index 4752a38..d22b7b7 100644 --- a/src/View.zig +++ b/src/View.zig @@ -3,7 +3,7 @@ //! //! Dragging a pane rearranges the view live rather than on release. Each time //! the drop target changes, the move is applied for real, so what you see -//! during the drag is the layout you will get. If the drag is cancelled the +//! during the drag is the layout you will get. If the drag is canceled the //! pane goes back where it came from, which is possible because only the //! dragged pane ever moves: the rest of the tree is unchanged by a move, so //! re-inserting it next to its original sibling restores the original shape. @@ -40,7 +40,7 @@ pub const Target = struct { const Drag = struct { pane: *Pane, - /// Where the pane sat when the drag began, so a cancelled drag can put + /// Where the pane sat when the drag began, so a canceled drag can put /// it back. origin_sibling: *Layout.Node, origin_side: Side, @@ -50,7 +50,7 @@ const Drag = struct { previewed: ?Target = null, /// Set once a drop has been accepted; a drag that ends without this was - /// cancelled and must be undone. + /// canceled and must be undone. committed: bool = false, }; @@ -101,7 +101,12 @@ on_status: *const fn (ctx: ?*anyopaque) void, /// last here" cannot be recovered by looking at the panes afterwards, since /// one that finished before your last visit looks identical to one that /// finished after it. -on_finished: *const fn (ctx: ?*anyopaque) void, +/// +/// Carries the finished pane's title, which is what a desktop notification says +/// under the tab's name. It comes with the edge rather than being read off the +/// view afterwards for the same reason the edge does: in a split, the pane that +/// finished is not necessarily the one whose title the tab is wearing. +on_finished: *const fn (ctx: ?*anyopaque, task: []const u8) void, /// Something in here asked for a review pane. /// @@ -115,7 +120,7 @@ pub const Callbacks = struct { on_empty: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque) void, on_status: *const fn (ctx: ?*anyopaque) void, - on_finished: *const fn (ctx: ?*anyopaque) void, + on_finished: *const fn (ctx: ?*anyopaque, task: []const u8) void, on_review: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque, }; @@ -167,7 +172,7 @@ pub fn widget(self: *View) *gtk.Widget { // arrangement rather than rebuilding an approximation of it. /// Put the view on screen. Every structural change goes through here rather -/// than calling the layout directly, so zoom is honoured from one place +/// than calling the layout directly, so zoom is honored from one place /// instead of being re-checked at each call site. fn render(self: *View) void { if (self.zoomed) |pane| { @@ -418,9 +423,8 @@ pub fn setFocused(self: *View, pane: *Pane) void { } pub fn paneFinished(self: *View, pane: *Pane) void { - _ = pane; if (self.closing) return; - self.on_finished(self.ctx); + self.on_finished(self.ctx, pane.titleSlice()); } pub fn paneTitleChanged(self: *View, pane: *Pane) void { @@ -685,7 +689,7 @@ fn reattachAtRoot(self: *View, node: *Layout.Node) void { } /// Move the focused pane one step in a direction: the keyboard equivalent of -/// dragging it onto the neighbouring pane. +/// dragging it onto the neighboring pane. pub fn moveFocused(self: *View, side: Side) void { const pane = self.focusedPane() orelse return; if (self.panes.items.len < 2) return; @@ -707,7 +711,7 @@ pub fn moveFocused(self: *View, side: Side) void { /// Move focus one step in a direction, to whichever pane is sitting there. /// -/// The counterpart of `moveFocused`: same neighbour, but the pane stays where +/// The counterpart of `moveFocused`: same neighbor, but the pane stays where /// it is and you go to it instead. Both are answered geometrically rather than /// by walking the split tree, so "the pane to the left" means the one that /// looks like it — a tree walk would have to pick between siblings and cousins, @@ -723,7 +727,7 @@ pub fn focusNeighbor(self: *View, side: Side) bool { // While zoomed there is only one pane on screen and the others are // unparented, so there is nothing to probe for and nothing to move to. The // arrangement is exactly what zoom is hiding; leaving it to hunt for a - // neighbour would undo what the user asked for. + // neighbor would undo what the user asked for. if (self.zoomed != null) return false; const target = self.neighbor(pane, side) orelse return false; @@ -773,7 +777,7 @@ pub fn beginDrag(self: *View, pane: *Pane) bool { // Only the zoomed pane is on screen, so there is nothing to drop against // and no preview to show. Refusing the drag is better than starting one - // that can only ever be cancelled. + // that can only ever be canceled. if (self.zoomed != null) return false; const node = self.layout.find(pane) orelse return false; diff --git a/src/Window.zig b/src/Window.zig index c13e2f8..ebd0b69 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -28,6 +28,7 @@ const View = @import("View.zig"); const appearance = @import("appearance.zig"); const emoji = @import("emoji.zig"); const key = @import("key.zig"); +const notify = @import("notify.zig"); const review = @import("review.zig"); const shortcuts = @import("shortcuts.zig"); @@ -132,7 +133,7 @@ layout_popover: *gtk.Popover, /// they belong to are on screen. layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty, -/// What a sidebar row is signalling. Defined with the dots themselves, since +/// What a sidebar row is signaling. Defined with the dots themselves, since /// a row and a pane header show the same five states for the same reasons. const Attention = Pane.Attention; @@ -155,17 +156,17 @@ const Source = struct { /// /// Shaped like the pane drag in `View`, and for the same reason: the reorder is /// applied as the pointer moves rather than on the drop, so the sidebar under -/// the cursor is always the order you will get. That means a cancelled drag has +/// the cursor is always the order you will get. That means a canceled drag has /// something to undo, which is what `origin` is for. const Drag = struct { tab: *Tab, - /// Where the tab sat in `tabs` when the drag began, so a cancelled drag can + /// Where the tab sat in `tabs` when the drag began, so a canceled drag can /// put it back. origin: usize, /// Set once a drop has been accepted; a drag that ends without this was - /// cancelled, and the preview has to be undone. + /// canceled, and the preview has to be undone. committed: bool = false, }; @@ -177,7 +178,7 @@ const Tab = struct { row: *gtk.ListBoxRow, label: *gtk.Label, - /// Shows what the tab's focused pane is, so a web view is recognisable in + /// Shows what the tab's focused pane is, so a web view is recognizable in /// the sidebar without reading the title. icon: *gtk.Image, @@ -228,6 +229,13 @@ const Tab = struct { /// with me?", which only you can answer. finished_since_visit: bool = false, + /// Whether this tab's finishes are posting notifications, and until when. + /// + /// Session state rather than a setting: a mute is set from the row menu to + /// get through the next hour, and a tab does not survive the app anyway, so + /// there is nothing here worth writing to a file. See `notify.Mute`. + mute: notify.Mute = .off, + name: [16]u8, name_len: usize, @@ -334,7 +342,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { // One drop target for the whole list rather than one per row, so that the // gaps between rows and the empty space under the last one are part of it: - // a drag to the bottom of the sidebar should land there, not be cancelled + // a drag to the bottom of the sidebar should land there, not be canceled // for having missed every row by a few pixels. self.installRowDropTarget(); @@ -605,18 +613,15 @@ fn bindReview(self: *Window, tab: *Tab) void { // // It does not select the row it belongs to. Renaming a tab, or giving it an // emoji, is not a reason to go and look at it — often it is the opposite, since -// the tab you are labelling is the one you are about to leave alone for a while. +// the tab you are labeling is the one you are about to leave alone for a while. /// Attach the row's context menu and the right-click that opens it. +/// +/// The menu's contents are not built here — see `fillRowMenu`, which builds them +/// as it opens. fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void { _ = self; - const box = gtk.Box.new(.vertical, 2); - box.as(gtk.Widget).addCssClass("playpen-row-menu"); - box.append(menuItem("Rename", &onMenuRename, tab)); - box.append(menuItem("Settings…", &onMenuSettings, tab)); - - tab.menu_popover.setChild(box.as(gtk.Widget)); tab.menu_popover.setHasArrow(0); tab.menu_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget)); @@ -632,6 +637,53 @@ fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void { anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController)); } +/// Build the menu's contents, immediately before it opens. +/// +/// Rebuilt per opening rather than built once and switched about, because half +/// of what it says is a deadline that has been quietly passing while the popover +/// sat there unopened. There is no moment other than "now" at which "muted for +/// another forty minutes" can be made true. +fn fillRowMenu(tab: *Tab) void { + const box = gtk.Box.new(.vertical, 2); + box.as(gtk.Widget).addCssClass("playpen-row-menu"); + box.append(menuItem("Rename", &onMenuRename, tab)); + box.append(menuItem("Settings…", &onMenuSettings, tab)); + + box.append(gtk.Separator.new(.horizontal).as(gtk.Widget)); + + // Cleared as the menu opens as well as when something wants to post, so a + // mute that ran out an hour ago isn't still described as one. + const now = notify.nowMs(); + if (tab.mute.expired(now)) tab.mute = .off; + + var buf: [80]u8 = undefined; + if (tab.mute.describe(now, &buf)) |state| { + // A label rather than an insensitive menu item: this is the answer to + // "why has this tab gone quiet", and there is nothing to click. + const label = gtk.Label.new(state); + label.setXalign(0); + label.as(gtk.Widget).addCssClass("playpen-row-menu-state"); + box.append(label.as(gtk.Widget)); + + box.append(menuItem("Unmute", &onMenuUnmute, tab)); + } + + // Offered whether or not the tab is already muted: re-picking is how you + // change your mind about how long, and having to unmute first to mute again + // for longer would be a menu arguing with you. + inline for (std.enums.values(notify.Duration)) |duration| { + const Item = struct { + fn clicked(_: *gtk.Button, clicked_tab: *Tab) callconv(.c) void { + clicked_tab.menu_popover.popdown(); + clicked_tab.mute = duration.mute(notify.nowMs()); + } + }; + box.append(menuItem(duration.label(), &Item.clicked, tab)); + } + + tab.menu_popover.setChild(box.as(gtk.Widget)); +} + /// One line of the row menu, styled like the layout menu's rows so the two /// popovers read as the same kind of thing. fn menuItem( @@ -647,7 +699,7 @@ fn menuItem( return button.as(gtk.Widget); } -/// Open the menu where the pointer is, rather than centred on the row: with one +/// Open the menu where the pointer is, rather than centered on the row: with one /// popover per row anchored to the whole row, a fixed position would put the /// menu somewhere you weren't pointing. fn onRowSecondary( @@ -663,6 +715,7 @@ fn onRowSecondary( .f_width = 1, .f_height = 1, }; + fillRowMenu(tab); tab.menu_popover.setPointingTo(&at); tab.menu_popover.popup(); } @@ -677,6 +730,11 @@ fn onMenuSettings(_: *gtk.Button, tab: *Tab) callconv(.c) void { tab.window.openTabSettings(tab); } +fn onMenuUnmute(_: *gtk.Button, tab: *Tab) callconv(.c) void { + tab.menu_popover.popdown(); + tab.mute = .off; +} + // ------------------------------------------------------------------------- // Per-tab settings @@ -1290,12 +1348,16 @@ fn select(self: *Window, tab: *Tab) void { self.list.selectRow(tab.row); tab.view.focus(); - // Opening the tab is the acknowledgement the row was asking for, and the + // Opening the tab is the acknowledgment the row was asking for, and the // pane you land in counts as answered along with it. Any other pane in a // split keeps its own dot until you go to it. tab.finished_since_visit = false; tab.view.answerFocused(); self.refreshStatus(tab); + + // And it answers the popup too, which would otherwise sit in the tray + // telling you about a tab you are now looking at. + if (self.application()) |app| notify.withdraw(app, tab.pageName()); } fn indexOf(self: *Window, tab: *Tab) ?usize { @@ -1416,7 +1478,7 @@ fn onRowDragPrepare( // A lone tab has nothing to be reordered against, so there is no preview to // show and no drop that could change anything. Refusing the drag is better - // than starting one that can only ever be cancelled. + // than starting one that can only ever be canceled. if (self.tabs.items.len < 2) return null; self.drag = .{ .tab = tab, .origin = self.indexOf(tab) orelse return null }; @@ -1694,7 +1756,7 @@ fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void { self.openSettings(); } -/// The colour scheme changed. Everything styled by CSS restyles itself; the +/// The color scheme changed. Everything styled by CSS restyles itself; the /// terminal grids do not, because Cairo draws them from `theme.zig` and GTK /// has no idea that widget's contents depend on the palette at all. Without /// this, switching to light leaves every terminal a dark rectangle until @@ -1702,7 +1764,7 @@ fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void { /// /// Sessions already open are re-palletted rather than left on the one they /// started in: a shell you have had running all day is exactly the one you are -/// looking at when you switch, and leaving it in the old scheme's colours +/// looking at when you switch, and leaving it in the old scheme's colors /// would make the setting look like it only applies to new tabs. fn onAppearanceChanged(ctx: ?*anyopaque) void { const self: *Window = @ptrCast(@alignCast(ctx.?)); @@ -1810,10 +1872,54 @@ fn onViewStatus(ctx: ?*anyopaque) void { /// Recorded even when the tab is the one on screen: you may well have watched /// it stop and then gone somewhere else, and the next time you open this tab is /// when that stops being news. -fn onViewFinished(ctx: ?*anyopaque) void { +fn onViewFinished(ctx: ?*anyopaque, task: []const u8) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); tab.finished_since_visit = true; + + // The row first, always, and the popup second, maybe: the dot is the half + // that is never wrong and never unwanted. tab.window.refreshStatus(tab); + tab.window.notifyFinished(tab, task); +} + +/// Tell the desktop that a pane finished, unless something says not to. +/// +/// See `notify.wanted` for the three things that can say not to. +fn notifyFinished(self: *Window, tab: *Tab, task: []const u8) void { + // A timed mute is cleared here rather than on a timer. Nothing needs to + // know it has run out until something wants to post, and a GLib timeout per + // muted tab would be a great deal of machinery for a deadline nobody is + // watching. + const now = notify.nowMs(); + if (tab.mute.expired(now)) tab.mute = .off; + + // "You watched it happen" is the window having the focus and this being the + // tab it is showing. Everything the decision rests on is gathered here and + // weighed in `notify.wanted`, which is where it can be tested. + const watching = self.window.as(gtk.Window).isActive() != 0 and self.activeTab() == tab; + + if (!notify.wanted(.{ + .enabled = Settings.get().notifications, + .mute = tab.mute, + .watching = watching, + }, now)) return; + + const app = self.application() orelse return; + + var buf: [192]u8 = undefined; + notify.post(app, tab.pageName(), self.tabName(tab, &buf), task); +} + +/// The `GApplication` this window belongs to, which is what carries a +/// notification to the session. +/// +/// Fetched each time rather than held. It is the same object for the life of the +/// process, but a window part-way through teardown has already been unparented +/// from it, and a session exiting during teardown is exactly when a finish can +/// still arrive. +fn application(self: *Window) ?*gio.Application { + const app = self.window.as(gtk.Window).getApplication() orelse return null; + return app.as(gio.Application); } /// The view lost its last pane, so the tab goes with it. @@ -2145,7 +2251,7 @@ fn addReviewPane(self: *Window, tab: *Tab) void { /// repositories reviews the one you are looking at. /// /// Falls back to playpen's own working directory, which at least gives the -/// server something to fail on that the user can recognise in the message. +/// server something to fail on that the user can recognize in the message. fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 { _ = self; @@ -2160,7 +2266,7 @@ fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 { } // Playpen's own directory, which at least gives the server something to - // fail on that the user can recognise in the message. `std.c` rather than + // fail on that the user can recognize in the message. `std.c` rather than // `std.posix`, matching `Pty.zig`: the latter has been churning across Zig // releases and this is one call. if (std.c.getcwd(buf.ptr, buf.len) != null) { diff --git a/src/appearance.zig b/src/appearance.zig index 185a9ba..c2f7a9c 100644 --- a/src/appearance.zig +++ b/src/appearance.zig @@ -1,14 +1,14 @@ //! Which palette the app is painted with, and the plumbing that keeps three -//! separate colour systems agreeing about it. +//! separate color systems agreeing about it. //! -//! * **libadwaita's style manager** colours every stock widget — popovers, +//! * **libadwaita's style manager** colors every stock widget — popovers, //! entries, scrollbars, dialog chrome. It is told to force light or dark, or //! left on `default` so it follows the desktop. -//! * **`style.css`** colours everything the app draws for itself. It is -//! written entirely against named colours; `palette.zig` supplies their +//! * **`style.css`** colors everything the app draws for itself. It is +//! written entirely against named colors; `palette.zig` supplies their //! values for the scheme in force, and the two are loaded as a single //! provider. -//! * **`theme.zig`** holds the colours the terminal renderer reads. Cairo never +//! * **`theme.zig`** holds the colors the terminal renderer reads. Cairo never //! consults the style tree, so a CSS reload on its own would leave every //! terminal grid painted in the palette it started with. //! @@ -19,7 +19,7 @@ //! that property means a desktop that changes scheme at sunset repaints this //! app too, with no extra machinery and no second source of truth. //! -//! Editing a colour comes in through `refresh`, which is the same repaint +//! Editing a color 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. @@ -33,14 +33,14 @@ const Settings = @import("Settings.zig"); const palette = @import("palette.zig"); const theme = @import("theme.zig"); -/// The stylesheet, which is the same in both schemes: every colour in it is a +/// The stylesheet, which is the same in both schemes: every color in it is a /// name, and the names are defined by the block `palette.zig` writes ahead of /// it. const style = @embedFile("style.css"); /// 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 +/// color 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; @@ -60,7 +60,7 @@ fn buildCss(scheme: palette.Scheme) [:0]const u8 { } /// 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 color-dependent — the Cairo-drawn terminal grids. pub const Callback = *const fn (ctx: ?*anyopaque) void; var provider: ?*gtk.CssProvider = null; @@ -141,7 +141,7 @@ fn onDarkChanged( /// 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 +/// This is what the color editor calls after changing a color: 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 { diff --git a/src/emoji.zig b/src/emoji.zig index 4d69fe5..1ad4f5c 100644 --- a/src/emoji.zig +++ b/src/emoji.zig @@ -10,7 +10,7 @@ //! Unicode lists nearly twice this many fully-qualified sequences and the //! difference is almost entirely the same gesture in five tones, which makes a //! grid longer to look through without making it more complete. The version -//! cutoff is there because colour emoji fonts trail Unicode, and a glyph the +//! cutoff is there because color emoji fonts trail Unicode, and a glyph the //! font has never heard of draws as a hex-digit box; the generator's header //! explains how to raise it. //! diff --git a/src/notify.zig b/src/notify.zig new file mode 100644 index 0000000..548be05 --- /dev/null +++ b/src/notify.zig @@ -0,0 +1,336 @@ +//! Desktop notifications when a pane finishes, and the two ways of not getting +//! one. +//! +//! This exists for the window you are not looking at. A pane that finishes +//! already lights its sidebar row and its dot — see `Pane.Attention` — but a row +//! is only news if the window is in front of you, and Playpen is a window you +//! set an agent going in and then leave. The finish worth telling someone about +//! is the one that lands while the window is behind an editor or on another +//! workspace, which is precisely the case the dots cannot reach. +//! +//! Sent as a `GNotification` rather than by calling +//! `org.freedesktop.Notifications` ourselves. GIO picks whichever backend the +//! session actually has — the portal, GNOME's own interface, or the freedesktop +//! one — and sends under the application id, which is what gets the popup +//! Playpen's name and icon from the installed desktop entry. +//! +//! Nothing is attached to a click. The app is deliberately non-unique (see +//! `main.zig`), so no process owns `dev.greyson.playpen` on the session bus and +//! a daemon trying to call an action back would find nobody home. A popup that +//! looks clickable and does nothing is worse than one that plainly just tells +//! you something, so no default action is set: clicking closes it. Getting back +//! to the window is the compositor's job and it already has a binding for it. +//! +//! Two mutes, and they compose. The setting (Settings → Notifications) is the +//! whole app, for when you want to be left alone for an afternoon; a tab can +//! also be muted on its own from its row menu, for a while or until you say +//! otherwise, for the one tab whose agent finishes every ninety seconds. +//! Neither touches the dots — muting is about the popups, and the row is still +//! how you find out what happened while you were away. + +const std = @import("std"); +const gio = @import("gio"); +const glib = @import("glib"); + +/// The clock a mute's deadline is measured against, in milliseconds. +/// +/// GLib's monotonic clock rather than the wall clock. A mute is a deadline a few +/// minutes out, and the wall clock can move under it — a suspend, an NTP step, +/// somebody's timezone — in ways that would either free a muted tab hours early +/// or leave it silent for the rest of the day. Milliseconds because nothing here +/// is finer grained than a minute. +pub fn nowMs() i64 { + return @divTrunc(glib.getMonotonicTime(), std.time.us_per_ms); +} + +/// How much of a title and a body we pass on. +/// +/// Both come from a terminal, so both can be any length at all, and every +/// notification daemon truncates to what its popup can show anyway. Clamping +/// here is about the buffer these are copied into to be NUL-terminated for GIO, +/// not about the layout of anyone's popup. +const title_max = 96; +const body_max = 240; + +/// Whether a tab's notifications are switched off, and until when. +/// +/// A timestamp rather than a countdown because nothing here runs on a timer: a +/// mute is checked at the only moment it matters, which is when something is +/// about to be posted. A `.until` in the past is a mute that has quietly run +/// out, and `expired` is how a caller notices and puts the tab back to `.off`. +pub const Mute = union(enum) { + /// Notifications reach this tab. + off, + + /// Silent until this instant, in milliseconds since the epoch. + until: i64, + + /// Silent until someone says otherwise. + forever, + + /// Whether a notification posted at `now` should be dropped. + pub fn silent(self: Mute, now: i64) bool { + return switch (self) { + .off => false, + .until => |deadline| now < deadline, + .forever => true, + }; + } + + /// Whether this is a timed mute whose time is up, so the caller can put the + /// tab back to `.off` and stop describing it as muted. + pub fn expired(self: Mute, now: i64) bool { + return switch (self) { + .until => |deadline| now >= deadline, + .off, .forever => false, + }; + } + + /// What the row menu says about the mute above the choices, or null when + /// the tab isn't muted and the menu should say nothing at all. Terminated, + /// since its one caller hands it straight to a `GtkLabel`. + /// + /// Time remaining rather than a wall-clock deadline: "another 40 minutes" is + /// the question someone reopening this menu is asking, and it needs no + /// timezone, which the alternative would. + pub fn describe(self: Mute, now: i64, buf: []u8) ?[:0]const u8 { + return switch (self) { + .off => null, + .forever => "Muted until you unmute it", + .until => |deadline| blk: { + const left_min = @divTrunc(deadline - now + 59_999, std.time.ms_per_min); + if (left_min <= 1) break :blk "Muted for another minute"; + if (left_min < 60) break :blk std.fmt.bufPrintZ( + buf, + "Muted for another {d} minutes", + .{left_min}, + ) catch "Muted"; + + // Rounded down and in hours past the first: someone who muted a + // tab for eight hours wants to know it is still most of the + // afternoon, not that it is 437 minutes. + const left_hours = @divTrunc(left_min, 60); + break :blk std.fmt.bufPrintZ( + buf, + "Muted for another {d} hours", + .{left_hours}, + ) catch "Muted"; + }, + }; + } +}; + +/// The mutes the row menu offers, in the order it lists them. +/// +/// A table rather than four handlers written out, so the menu and the durations +/// it can produce cannot drift apart: adding a choice here adds a line to every +/// row menu. +pub const Duration = enum { + fifteen_minutes, + one_hour, + eight_hours, + indefinitely, + + /// Minutes this silences a tab for. Null is `indefinitely`, which has no + /// end and so is not a number of minutes. + fn minutes(self: Duration) ?i64 { + return switch (self) { + .fifteen_minutes => 15, + .one_hour => 60, + .eight_hours => 8 * 60, + .indefinitely => null, + }; + } + + /// The menu line. Phrased as the thing it does rather than as a bare + /// duration, since these sit in a list beside "Rename" and "Settings…". + pub fn label(self: Duration) [:0]const u8 { + return switch (self) { + .fifteen_minutes => "Mute for 15 minutes", + .one_hour => "Mute for an hour", + .eight_hours => "Mute for 8 hours", + .indefinitely => "Mute until I unmute it", + }; + } + + pub fn mute(self: Duration, now: i64) Mute { + const mins = self.minutes() orelse return .forever; + return .{ .until = now + mins * std.time.ms_per_min }; + } +}; + +/// Everything that gets to decide whether a finish reaches the desktop. +pub const Decision = struct { + /// The app-wide setting: Settings → Notifications. + enabled: bool, + + /// The tab's own mute, from its row menu. + mute: Mute, + + /// Whether you watched it happen — the window has the focus and this is the + /// tab it is showing. + watching: bool, +}; + +/// Whether a finish should be told to anyone. +/// +/// Its own function, taking everything as arguments, because the interesting +/// half of notifications is the ones that *don't* arrive, and that half is +/// otherwise only observable by sitting in front of the app with a stopwatch. +/// The window gathers the three answers; this weighs them. +pub fn wanted(decision: Decision, now: i64) bool { + if (!decision.enabled) return false; + if (decision.mute.silent(now)) return false; + + // The dot in the sidebar has already said it, and a popup about the pane you + // are sitting in front of is the fastest way to teach someone to switch the + // whole feature off. + return !decision.watching; +} + +/// Post a notification, or fail quietly. +/// +/// `id` replaces rather than stacks: a tab whose agent finishes three times +/// before you come back should leave one popup behind, not three, and passing +/// the same id each time is what the notification protocols mean by that. It is +/// also what `withdraw` takes the popup down by. +pub fn post(app: *gio.Application, id: [:0]const u8, title: []const u8, body: []const u8) void { + var title_buf: [title_max + 1]u8 = undefined; + const title_z = terminate(&title_buf, title, "Finished"); + + const notification = gio.Notification.new(title_z); + defer notification.unref(); + + if (body.len > 0) { + var body_buf: [body_max + 1]u8 = undefined; + notification.setBody(terminate(&body_buf, body, "")); + } + + // Not `.high`: a finished task is something you want to know, not something + // that should survive a do-not-disturb or push past whatever else is on + // screen. The people most likely to run this are the people least likely to + // want an interruption. + notification.setPriority(.normal); + + app.sendNotification(id, notification); +} + +/// Take down whatever `post` left on screen for `id`, if the session's daemon +/// supports it. A no-op where it doesn't, and harmless where there is nothing +/// showing. +pub fn withdraw(app: *gio.Application, id: [:0]const u8) void { + app.withdrawNotification(id); +} + +/// Copy `text` into `buf` NUL-terminated, clamped to fit, falling back to +/// `empty` when there is nothing to copy. +/// +/// The clamp walks back off a UTF-8 continuation byte rather than cutting where +/// the length happens to land. Tab titles here routinely end in an emoji — the +/// app has a picker for putting one on a row — and half a codepoint is a popup +/// that either shows a replacement box or gets dropped by the daemon for being +/// invalid UTF-8. +fn terminate(buf: []u8, text: []const u8, comptime empty: [:0]const u8) [:0]const u8 { + if (text.len == 0) return empty; + + var n = @min(text.len, buf.len - 1); + while (n < text.len and n > 0 and text[n] & 0xC0 == 0x80) n -= 1; + + @memcpy(buf[0..n], text[0..n]); + buf[n] = 0; + return buf[0..n :0]; +} + +test "a mute that has not run out is silent, and then isn't" { + const now: i64 = 1_000_000; + const mute: Mute = .{ .until = now + 5 * std.time.ms_per_min }; + + try std.testing.expect(mute.silent(now)); + try std.testing.expect(!mute.expired(now)); + + const later = now + 6 * std.time.ms_per_min; + try std.testing.expect(!mute.silent(later)); + try std.testing.expect(mute.expired(later)); +} + +test "off and forever never expire" { + const now: i64 = 1_000_000; + const off: Mute = .off; + const forever: Mute = .forever; + + try std.testing.expect(!off.silent(now)); + try std.testing.expect(!off.expired(now)); + + try std.testing.expect(forever.silent(now)); + try std.testing.expect(!forever.expired(now)); +} + +test "every duration produces a mute that is silent now" { + const now: i64 = 1_000_000; + for (std.enums.values(Duration)) |duration| { + try std.testing.expect(duration.mute(now).silent(now)); + try std.testing.expect(duration.label().len > 0); + } +} + +test "a timed mute reads back as time remaining" { + const now: i64 = 1_000_000; + var buf: [64]u8 = undefined; + + const off: Mute = .off; + const forever: Mute = .forever; + try std.testing.expect(off.describe(now, &buf) == null); + try std.testing.expectEqualStrings("Muted until you unmute it", forever.describe(now, &buf).?); + + const quarter = Duration.fifteen_minutes.mute(now); + try std.testing.expectEqualStrings("Muted for another 15 minutes", quarter.describe(now, &buf).?); + + const day = Duration.eight_hours.mute(now); + try std.testing.expectEqualStrings("Muted for another 8 hours", day.describe(now, &buf).?); + + // The last minute rounds up to a minute rather than down to none: a mute + // with thirty seconds left is still a mute, and "another 0 minutes" reads + // like a bug. + const nearly = Mute{ .until = now + 30 * std.time.ms_per_s }; + try std.testing.expectEqualStrings("Muted for another minute", nearly.describe(now, &buf).?); +} + +test "nothing is posted for a tab you are looking at" { + const now: i64 = 1_000_000; + const watched: Decision = .{ .enabled = true, .mute = .off, .watching = true }; + const elsewhere: Decision = .{ .enabled = true, .mute = .off, .watching = false }; + + try std.testing.expect(!wanted(watched, now)); + try std.testing.expect(wanted(elsewhere, now)); +} + +test "either mute is enough to stop a post" { + const now: i64 = 1_000_000; + + // The setting off, everything else willing. + try std.testing.expect(!wanted(.{ .enabled = false, .mute = .off, .watching = false }, now)); + + // The setting on, this one tab muted — for a while, and then not. + const timed = Duration.one_hour.mute(now); + try std.testing.expect(!wanted(.{ .enabled = true, .mute = timed, .watching = false }, now)); + try std.testing.expect(wanted( + .{ .enabled = true, .mute = timed, .watching = false }, + now + 61 * std.time.ms_per_min, + )); + + // And indefinitely means indefinitely. + try std.testing.expect(!wanted( + .{ .enabled = true, .mute = .forever, .watching = false }, + now + 400 * std.time.ms_per_day, + )); +} + +test "a title is clamped without splitting a codepoint" { + var buf: [8]u8 = undefined; + + // Seven bytes of room and a three-byte glyph at the boundary: the glyph goes + // whole rather than being cut in the middle. + try std.testing.expectEqualStrings("abcde", terminate(&buf, "abcde🙂", "")); + try std.testing.expectEqualStrings("abc🙂", terminate(&buf, "abc🙂", "")); + try std.testing.expectEqualStrings("(none)", terminate(&buf, "", "(none)")); +} diff --git a/src/oklab.zig b/src/oklab.zig index 6cef7f0..c93eacf 100644 --- a/src/oklab.zig +++ b/src/oklab.zig @@ -1,6 +1,6 @@ //! sRGB in, sRGB out, with a perceptual space in the middle. //! -//! This exists for `tint.zig`, which builds a whole palette out of one colour +//! This exists for `tint.zig`, which builds a whole palette out of one color //! and therefore has to answer questions like "the same hue, a third as bright" //! and "one step lighter than that". Those questions have no good answer in RGB //! — halving the channels of a mid blue gives a navy, halving the channels of a @@ -14,10 +14,10 @@ //! polar form — lightness, chroma, hue — is what a palette is really made of: //! hold the hue, walk the lightness, and the surfaces of a theme fall out. //! -//! The one thing OKLCh will happily do that sRGB will not is name a colour that +//! The one thing OKLCh will happily do that sRGB will not is name a color that //! does not exist on a monitor — a fully saturated yellow at the lightness of a //! midtone, say. `toRgb` deals with that by giving up chroma rather than -//! lightness or hue: the result is the most colourful version of the colour that +//! lightness or hue: the result is the most colorful version of the color that //! can actually be shown, which is what "as close as the screen gets" should //! mean for a palette. Clipping the channels instead would shift the hue, and a //! generated theme whose reds drift orange as they darken looks broken in a way @@ -31,13 +31,13 @@ const palette = @import("palette.zig"); const Rgb = palette.Rgb; -/// A colour in OKLCh: how light, how colourful, and which colour. +/// A color in OKLCh: how light, how colorful, and which color. pub const Lch = struct { /// Perceptual lightness. 0 is black, 1 is white, and 0.5 looks like a /// midtone rather than merely being one arithmetically. l: f32, - /// Colourfulness. 0 is a grey; how high it can go before leaving sRGB + /// Colorfulness. 0 is a gray; how high it can go before leaving sRGB /// depends on both the lightness and the hue, and is around 0.32 at best. c: f32, @@ -59,17 +59,17 @@ pub fn fromRgb(rgb: Rgb) Lch { return .{ .l = lab.l, .c = c, - // A grey has no hue to report, and `atan2` on two zeroes is entitled to + // A gray has no hue to report, and `atan2` on two zeroes is entitled to // say anything. Zero is as good as any other answer and is at least the // same one every time, which matters: the palette generator reads a hue - // off the base colour and gives it to forty other colours, and a grey + // off the base color and gives it to forty other colors, and a gray // base that produced a different hue on each launch would be a theme - // that changed colour when you restarted the app. + // that changed color when you restarted the app. .h = if (c < 1e-6) 0 else std.math.radiansToDegrees(std.math.atan2(lab.b, lab.a)), }; } -/// The nearest colour a screen can show, giving up chroma before anything else. +/// The nearest color a screen can show, giving up chroma before anything else. pub fn toRgb(lch: Lch) Rgb { const l = std.math.clamp(lch.l, 0, 1); const rad = std.math.degreesToRadians(lch.h); @@ -90,7 +90,7 @@ pub fn toRgb(lch: Lch) Rgb { /// is not one: it is the image of a cube through a cube root, and the closed /// forms for it are approximations with their own error. Twelve halvings of a /// range that is at most 1.0 wide lands well inside a 1/255 step, and this runs -/// forty times when someone drags a colour picker. +/// forty times when someone drags a color picker. fn fit(l: f32, c: f32, rad: f32) f32 { if (Lab.at(l, c, rad).inGamut()) return c; @@ -133,9 +133,9 @@ const Lab = struct { }; } - /// Whether this colour is one a screen can show. The tolerance is a hair + /// Whether this color is one a screen can show. The tolerance is a hair /// over half of an 8-bit step in linear light near the top of the range, so - /// a colour that is out of gamut only by the arithmetic isn't hunted down + /// a color that is out of gamut only by the arithmetic isn't hunted down /// by the bisection above for no visible gain. fn inGamut(self: Lab) bool { const lin = self.toLinear(); @@ -188,8 +188,8 @@ fn encode(channel: f32) u8 { // ------------------------------------------------------------------------- -test "a colour survives the round trip" { - // Every hue family, plus the two ends and a grey, since those are the cases +test "a color survives the round trip" { + // Every hue family, plus the two ends and a gray, since those are the cases // where the hue is undefined and the transfer function is at its steepest. const cases = [_][]const u8{ "#000000", "#ffffff", "#808080", "#3a76f0", "#d2691e", @@ -220,14 +220,14 @@ test "black and white are where they should be" { try std.testing.expectApproxEqAbs(@as(f32, 0), fromRgb(.{ .r = 0, .g = 0, .b = 0 }).l, 1e-4); try std.testing.expectApproxEqAbs(@as(f32, 1), fromRgb(.{ .r = 255, .g = 255, .b = 255 }).l, 1e-4); - // A grey has no hue, and says so rather than saying whatever `atan2` makes + // A gray has no hue, and says so rather than saying whatever `atan2` makes // of two zeroes. - const grey = fromRgb(.{ .r = 128, .g = 128, .b = 128 }); - try std.testing.expectApproxEqAbs(@as(f32, 0), grey.c, 1e-3); - try std.testing.expectEqual(@as(f32, 0), grey.h); + const gray = fromRgb(.{ .r = 128, .g = 128, .b = 128 }); + try std.testing.expectApproxEqAbs(@as(f32, 0), gray.c, 1e-3); + try std.testing.expectEqual(@as(f32, 0), gray.h); } -test "an impossible colour gives up chroma, not hue" { +test "an impossible color gives up chroma, not hue" { // A fully saturated yellow at the lightness of a midtone: nothing like it // exists in sRGB, and asking for it has to produce *something*. const asked: Lch = .{ .l = 0.5, .c = 0.3, .h = 100 }; @@ -237,7 +237,7 @@ test "an impossible colour gives up chroma, not hue" { try std.testing.expectApproxEqAbs(asked.h, got.h, 1.5); try std.testing.expect(got.c < asked.c); - // And it is still as colourful as sRGB allows, rather than having been + // And it is still as colorful as sRGB allows, rather than having been // rounded down to something safe: pushing it back up leaves the gamut. try std.testing.expect(!Lab.at(asked.l, got.c + 0.01, std.math.degreesToRadians(asked.h)).inGamut()); } diff --git a/src/palette.zig b/src/palette.zig index d20883d..55ceb8a 100644 --- a/src/palette.zig +++ b/src/palette.zig @@ -1,9 +1,9 @@ -//! The palette: every colour the app paints itself with, what each one defaults +//! The palette: every color 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 +//! and that was the right shape right up until the settings page grew a color //! 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 @@ -12,8 +12,8 @@ //! //! 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. +//! `style.css`, and `theme.zig` resolves the terminal renderer's colors out of +//! the same table. There is still exactly one place a color 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 @@ -21,10 +21,10 @@ //! touched it, rather than only the people who had never opened the editor. //! //! Under the overrides there is one more layer: a `Tint`, which is a single -//! colour that `tint.zig` builds an entire palette out of. It stands in for the +//! color that `tint.zig` builds an entire palette out of. It stands in for the //! defaults rather than beside them, so the three layers read outward from the -//! most specific — this colour, then the colour built from your base, then the -//! colour Playpen ships. That ordering is what lets someone pick a base and then +//! most specific — this color, then the color built from your base, then the +//! color Playpen ships. That ordering is what lets someone pick a base and then //! fix the two swatches they didn't like without the fix being undone the next //! time they nudge the base, and it is why `Changes` is a struct rather than the //! bare override array it used to be. @@ -89,7 +89,7 @@ pub const Rgb = struct { } }; -/// A resolved colour scheme. Not the same thing as the user's preference, which +/// A resolved color 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 { @@ -116,7 +116,7 @@ pub const Group = enum { terminal, ansi, - /// Whether this group's colours reach the UI as named CSS colours. The two + /// Whether this group's colors reach the UI as named CSS colors. 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 { @@ -134,7 +134,7 @@ pub const Group = enum { .accents => "Accent", .states => "Status", .terminal => "Terminal", - .ansi => "Terminal ANSI colours", + .ansi => "Terminal ANSI colors", }; } @@ -143,17 +143,17 @@ pub const Group = enum { 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. " ++ + "Left alone, these follow the surface, text and accent colors above.", + .ansi => "The 16 colors 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. +/// Every color in the palette. /// -/// The tag is the wire format — it is what a colour is called in +/// The tag is the wire format — it is what a color 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. @@ -195,7 +195,7 @@ pub const Key = enum { term_selection_bg, term_selection_fg, - // ---- the 16 named ANSI colours, in palette order ---- + // ---- the 16 named ANSI colors, in palette order ---- ansi_black, ansi_red, ansi_green, @@ -232,7 +232,7 @@ pub const Key = enum { }; } - /// The name `style.css` knows this colour by, for the groups that reach CSS + /// The name `style.css` knows this color 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 { @@ -244,16 +244,16 @@ pub const Key = enum { }; } - /// This colour's index in the 256-colour palette, for the 16 that have one. + /// This color's index in the 256-color 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. + /// The color 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 + /// surface without asking anyone to set the same color 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 @@ -318,7 +318,7 @@ pub const Key = enum { }; } - /// What this colour is for, for the ones where the name isn't the whole + /// What this color 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) { @@ -326,7 +326,7 @@ pub const Key = enum { .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", + .text_faint => "Things that are labeled 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", @@ -338,7 +338,7 @@ pub const Key = enum { }; } - /// The colour before any override: this key's own default, or the default of + /// The color 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) { @@ -360,17 +360,17 @@ pub const Key = enum { pub const count = std.enums.values(Key).len; -/// The colours someone has changed, per key, in one scheme. A fixed array +/// The colors 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); -/// One colour, and how far apart to spread everything built around it. See +/// One color, and how far apart to spread everything built around it. See /// `tint.zig` for what "built around it" means. pub const Tint = struct { - /// The colour the palette is generated from. It is also, near enough, what + /// The color the palette is generated from. It is also, near enough, what /// `accent` comes out as — see `tint.derive`. base: Rgb, @@ -384,8 +384,8 @@ pub const Tint = struct { } }; -/// Everything someone has done to one scheme's palette: the base colour it is -/// generated from, if any, and the individual colours they have set by hand. +/// Everything someone has done to one scheme's palette: the base color it is +/// generated from, if any, and the individual colors they have set by hand. /// /// Both, and in that order, rather than either — see the note at the top of the /// file. A base is a starting point and the overrides are the corrections to it, @@ -395,7 +395,7 @@ pub const Changes = struct { overrides: Overrides = no_overrides, /// Whether anything in this scheme has been changed at all. What the - /// editor's "reset everything" button is greyed out by. + /// editor's "reset everything" button is grayed out by. pub fn anySet(self: *const Changes) bool { if (self.tint != null) return true; for (std.enums.values(Key)) |key| { @@ -405,20 +405,20 @@ pub const Changes = struct { } }; -/// A scheme nobody has touched. Named so that the places that resolve a colour +/// A scheme nobody has touched. Named so that the places that resolve a color /// without any settings to hand — the terminal renderer's compile-time initial /// palette, and the tests — have something to point at. pub const unchanged: Changes = .{}; -/// The colour to paint with: the override if there is one, else what the key -/// inherits (overrides and all), else the colour a base generates for it, else +/// The color to paint with: the override if there is one, else what the key +/// inherits (overrides and all), else the color a base generates for it, 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 +/// which is the point of it — recolor `surface` and the terminal background /// follows, without anyone having had to set both. It is resolved *before* the /// generated palette for the same reason: a terminal background whose pane -/// surface has been overridden should follow the override, not the base colour +/// surface has been overridden should follow the override, not the base color /// the override was correcting. pub fn resolve(key: Key, scheme: Scheme, changes: *const Changes) Rgb { if (changes.overrides.get(key)) |color| return color; @@ -427,11 +427,11 @@ pub fn resolve(key: Key, scheme: Scheme, changes: *const Changes) Rgb { return key.default(scheme); } -/// Emit the palette as named CSS colours, for `style.css` to be written +/// Emit the palette as named CSS colors, 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 +/// single provider. Only the CSS groups appear; the terminal's colors go to /// Cairo instead, through `theme.zig`. pub fn writeCss( scheme: Scheme, @@ -457,17 +457,17 @@ pub const css_size = count * ("@define-color pp_surface_raised_active #rrggbb;\n // // 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. +// from both rather than repeating the color 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 +/// The default for a key the table below doesn't mention: no color of its own, +/// meaning it inherits one or is an ANSI color 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 +/// Deep navy rather than neutral gray, 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, .{ @@ -495,12 +495,12 @@ const dark: Table = .initDefault(unset, .{ // 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. + // where those are the same color 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 cursor is the one terminal color 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"), @@ -509,7 +509,7 @@ const dark: Table = .initDefault(unset, .{ /// 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` +/// colors 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 @@ -541,7 +541,7 @@ const light: Table = .initDefault(unset, .{ .term_cursor = h("#2c6bed"), }); -/// The 16 named colours, retuned for a light background. +/// The 16 named colors, 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 @@ -549,7 +549,7 @@ const light: Table = .initDefault(unset, .{ /// /// 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 +/// background they have to go dark or the text they color 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 @@ -574,18 +574,18 @@ const light_ansi = [16]Rgb{ }; /// 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 +/// palette wants to be read as the colors 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); + return Rgb.parse(text) orelse @compileError("not a color: " ++ text); } } -// Every key has to end up with a colour, or the palette has a hole in it that +// Every key has to end up with a color, 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| { @@ -634,7 +634,7 @@ test "ansi keys map to palette indices in order" { try std.testing.expectEqual(@as(?u8, null), Key.accent.ansiIndex()); } -test "an unset colour resolves to its default" { +test "an unset color resolves to its default" { try std.testing.expectEqual(Rgb.parse("#070c15").?, resolve(.bg, .dark, &unchanged)); try std.testing.expectEqual(Rgb.parse("#d9e1ee").?, resolve(.bg, .light, &unchanged)); } @@ -651,16 +651,16 @@ test "the terminal follows the surface it is drawn on" { changes.overrides.set(.surface, Rgb.parse("#123456").?); try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.term_bg, .dark, &changes)); - // ...and not once it has been given a colour of its own. + // ...and not once it has been given a color of its own. changes.overrides.set(.term_bg, Rgb.parse("#abcdef").?); try std.testing.expectEqual(Rgb.parse("#abcdef").?, resolve(.term_bg, .dark, &changes)); try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.surface, .dark, &changes)); } -test "a base colour stands in for the defaults, and an override for the base" { +test "a base color stands in for the defaults, and an override for the base" { var changes: Changes = .{ .tint = .{ .base = Rgb.parse("#d2691e").? } }; - // Nothing has been set by hand, so every colour is the generated one... + // Nothing has been set by hand, so every color is the generated one... try std.testing.expect(!resolve(.bg, .dark, &changes).eql(Key.bg.default(.dark))); try std.testing.expectEqual( Rgb.parse("#d2691e").?, @@ -673,7 +673,7 @@ test "a base colour stands in for the defaults, and an override for the base" { resolve(.term_bg, .dark, &changes), ); - // ...and a colour set by hand outranks the base rather than the other way + // ...and a color set by hand outranks the base rather than the other way // round, which is what lets the two be used together. changes.overrides.set(.accent, Rgb.parse("#00ff00").?); try std.testing.expectEqual(Rgb.parse("#00ff00").?, resolve(.accent, .dark, &changes)); @@ -691,7 +691,7 @@ test "anySet notices a base with nothing else changed" { try std.testing.expect(edited.anySet()); } -test "writeCss emits every named colour and no others" { +test "writeCss emits every named color and no others" { var buf: [css_size]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); diff --git a/src/review/Store.zig b/src/review/Store.zig index 2e827b9..ed4ba3d 100644 --- a/src/review/Store.zig +++ b/src/review/Store.zig @@ -218,7 +218,7 @@ fn save(self: *Store) Error!void { /// Deliberately not filtered by diff context. A comment is content someone /// typed: it has to survive the base ref moving, the working tree being /// committed, or the page being reloaded onto a different selection. Whether a -/// comment still lines up with the diff on screen is the frontend's judgement — +/// comment still lines up with the diff on screen is the frontend's judgment — /// it has the parsed diff, and it marks the ones it cannot place as outdated. pub fn list(self: *Store, gpa: std.mem.Allocator) ![]Comment { self.mutex.lockUncancelable(self.io); diff --git a/src/style.css b/src/style.css index 2a8e29d..bee3b89 100644 --- a/src/style.css +++ b/src/style.css @@ -1,15 +1,15 @@ /* Zen-style vertical tabs: a sidebar column with the terminal inset to its right. - Every colour here is a name from `palette.zig`, which writes the matching + Every color here is a name from `palette.zig`, which writes the matching `@define-color` block for the scheme in force; `appearance.zig` prepends that - and loads the pair as a single provider. Nothing below may hardcode a colour — + and loads the pair as a single provider. Nothing below may hardcode a color — a literal hex would be a rule that looks right in one scheme and wrong in the 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. + and the color 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 - state's tint is always derived from the state's own colour and the two + `alpha()` is used for every wash rather than a second named color, so a + state's tint is always derived from the state's own color and the two can't drift apart. */ .playpen-window { @@ -33,7 +33,7 @@ The rule underneath is stated explicitly rather than left to the one Adwaita draws for every header bar: that one is a shadow in the desktop theme's - colour, which on this palette read as a stray line rather than as the pair + color, which on this palette read as a stray line rather than as the pair it now makes with the footer's. */ .playpen-sidebar headerbar { background: none; @@ -44,7 +44,7 @@ } /* The two header actions. Styled explicitly rather than left to Adwaita, whose - default button is a grey that belongs to a different window than this one. + default button is a gray that belongs to a different window than this one. Scoped by class instead of by `headerbar button` so the window controls beside them keep their own appearance — those belong to the desktop, not to the app, and making them match would be the wrong kind of consistent. @@ -113,9 +113,9 @@ button.playpen-header-button:hover, } /* The kind icon picks up the accent on the current row. The status dot is - excluded: it carries a colour that *is* the information, and this rule is + excluded: it carries a color that *is* the information, and this rule is specific enough to beat the state classes, which used to leave the selected - row's dot accent-coloured whatever it was trying to say. */ + row's dot accent-colored whatever it was trying to say. */ .playpen-list > row:selected image:not(.playpen-status-dot) { color: @pp_accent_strong; } @@ -124,7 +124,7 @@ button.playpen-header-button:hover, rather than left to the glyph, so a sidebar mixing emoji rows with icon rows still starts every label in the same column — emoji vary in advance width and without this the labels beside them would step in and out by a pixel or two - per row. The font size is a little under the icon's 16px because a colour + per row. The font size is a little under the icon's 16px because a color glyph fills its box where a symbolic icon leaves air around itself. */ .playpen-tab-emoji { min-width: 16px; @@ -133,7 +133,7 @@ button.playpen-header-button:hover, /* State on the row itself, not just on its dot. - A bar down the leading edge, and behind the row a wash of the same colour for + A bar down the leading edge, and behind the row a wash of the same color for the two states that actually want you. The dot says what a row is doing once you are reading it; these are what let you sweep a sidebar of a dozen tabs and land on the one that wants you without reading any of them. @@ -178,7 +178,7 @@ button.playpen-header-button:hover, } /* The current row keeps its own background: the wash and the selection would - otherwise blend into a colour that reads as neither. The bar survives, which + otherwise blend into a color that reads as neither. The bar survives, which is the part that carries the state. Done is absent because it no longer has a wash to suppress. */ .playpen-list > row:selected.playpen-attn-input, @@ -201,7 +201,7 @@ button.playpen-header-button:hover, box-shadow: inset 3px 0 0 @pp_accent_strong; } -/* The copy of that row travelling with the pointer, which GTK draws into a +/* The copy of that row traveling with the pointer, which GTK draws into a surface of its own. What it paints is the row's *box* — the icon, the label and the close button — and a row's background belongs to the row above that box, so without this the label would float on nothing. Given the selected @@ -308,12 +308,12 @@ dnd.playpen-tab-drag { } /* Status dots. One shared appearance for the sidebar row and the pane header, - coloured entirely by which state class is on the icon. Symbolic icons take - their colour from `color`, so nothing here needs a second asset. + colored entirely by which state class is on the icon. Symbolic icons take + their color from `color`, so nothing here needs a second asset. - These are read at a glance from across a screen full of tabs, so the colours + These are read at a glance from across a screen full of tabs, so the colors are picked to survive that: amber and red carry the two states that actually - want you, and they are the only warm colours anywhere in the window. */ + want you, and they are the only warm colors anywhere in the window. */ .playpen-status-dot { -gtk-icon-size: 11px; opacity: 0; @@ -375,6 +375,23 @@ dnd.playpen-tab-drag { border-radius: 6px; } +/* The rule between what a row *is* and whether it is allowed to interrupt you. + Inset from the popover's edges so it reads as a divider inside a list rather + than as the list being cut in two. */ +.playpen-row-menu > separator { + margin: 3px 6px; + background-color: @pp_border; +} + +/* What a tab's mute is currently doing, above the ways out of it. Sized and + dimmed like the sublabels in the settings dialog, because it is the same kind + of thing: a caption on the controls under it, not a control. */ +.playpen-row-menu-state { + padding: 2px 8px; + color: @pp_text_faint; + font-size: 0.85em; +} + /* A web pane's navigation bar, below the pane header. Kept visually quieter than the header so the two rows don't compete. */ .playpen-nav { @@ -445,7 +462,7 @@ dnd.playpen-tab-drag { asking without being read pane by pane. Only the three states that are news get a border. Working deliberately does - not: it is the resting state of every pane you have set going, and colouring + not: it is the resting state of every pane you have set going, and coloring the frame for it would leave the whole view repainting itself all afternoon while saying nothing you did not already know. Its pulsing dot is enough. @@ -465,7 +482,7 @@ dnd.playpen-tab-drag { border-color: @pp_err; } -/* The header carries a wash of the same colour. The border alone is a hairline +/* The header carries a wash of the same color. The border alone is a hairline around a large shape; the header is a solid band right next to the dot and the title, which is what makes a pane readable at a glance in a four-way split. */ @@ -601,9 +618,9 @@ dnd.playpen-tab-drag { choice is visible without opening anything, and so the one you are on is readable at a glance rather than by reading a word. */ /* `background-image: none` on every rule below is load-bearing. Adwaita paints - button states with a gradient *over* the background colour, so setting the - colour alone leaves a washed-out version of it — most visibly on the checked - button, where the accent came out as a pale grey-blue. `:backdrop` is + button states with a gradient *over* the background color, so setting the + color alone leaves a washed-out version of it — most visibly on the checked + button, where the accent came out as a pale gray-blue. `:backdrop` is spelled out for the same reason: Adwaita dims unfocused windows, and the selected scheme is exactly the thing that has to stay readable while you are looking at the window behind this one to see what it did. */ @@ -639,11 +656,11 @@ dnd.playpen-tab-drag { } /* ------------------------------------------------------------------------- - The colour editor + The color editor - One row per colour in the palette, in sections. The rows are deliberately + One row per color 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 + be the only thing on a line carrying any color, and a bordered card per section would read as noise. The space between sections does that work instead. */ @@ -665,10 +682,10 @@ dnd.playpen-tab-drag { 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. */ +/* Adwaita gives a color button the full button treatment: a gradient over the + color, and a border in a gray belonging to neither palette. What is wanted + is a rectangle of the color with this app's border around it, so the swatch + reads as the color rather than as a button holding one. */ .playpen-color-group colorbutton > button { padding: 0; min-width: 0; @@ -684,9 +701,9 @@ dnd.playpen-tab-drag { } /* The contrast slider, for the same reason as the swatch above it: Adwaita - paints a scale in the *desktop's* accent colour, which on a page whose whole - subject is the colour this window is painted from would be the one thing on - screen ignoring it. Left stock, dragging it towards a warm theme leaves a + paints a scale in the *desktop's* accent color, which on a page whose whole + subject is the color this window is painted from would be the one thing on + screen ignoring it. Left stock, dragging it toward a warm theme leaves a blue bar sitting in the middle of the result. */ .playpen-contrast trough { background-color: @pp_surface_raised_active; @@ -789,7 +806,7 @@ dnd.playpen-tab-drag { } /* The glyph in force. `background-image: none` for the same reason as the theme - buttons above: Adwaita paints selection with a gradient over the colour, and + buttons above: Adwaita paints selection with a gradient over the color, and without this the accent comes out washed. */ .playpen-emoji-grid > flowboxchild:selected, .playpen-emoji-grid > flowboxchild:selected:backdrop { diff --git a/src/theme.zig b/src/theme.zig index 901e93e..5ea5364 100644 --- a/src/theme.zig +++ b/src/theme.zig @@ -1,7 +1,7 @@ -//! The colours the terminal renderer draws with, resolved for the scheme the +//! The colors the terminal renderer draws with, resolved for the scheme the //! app is currently in. //! -//! Everything the *widgets* are painted with reaches them as named CSS colours +//! Everything the *widgets* are painted with reaches them as named CSS colors //! (see `palette.zig` and `appearance.zig`). The terminal grid can't work that //! 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 @@ -24,7 +24,7 @@ const palette = @import("palette.zig"); pub const Rgb = palette.Rgb; pub const Scheme = palette.Scheme; -/// The colours of one scheme, already resolved through the user's overrides. +/// The colors of one scheme, already resolved through the user's overrides. const Resolved = struct { /// Terminal default background, used when the program hasn't set one. bg: Rgb, @@ -32,19 +32,19 @@ const Resolved = struct { /// Terminal default foreground. fg: Rgb, - /// Cursor block colour. + /// Cursor block color. cursor: Rgb, /// Background and foreground of selected text. Both are fixed rather than /// derived from the cell underneath, because a selection has to read as one - /// continuous block across text the program has coloured every which way — - /// a translucent wash over the existing colours leaves the highlight - /// looking like a rendering artefact wherever the text is already bright. + /// continuous block across text the program has colored every which way — + /// a translucent wash over the existing colors leaves the highlight + /// looking like a rendering artifact wherever the text is already bright. selection_bg: 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 + /// The full 256-color palette. The 240 above index 16 are fixed by the + /// xterm spec — a 6×6×6 cube and a gray 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, diff --git a/src/tint.zig b/src/tint.zig index 8d583bd..bb75698 100644 --- a/src/tint.zig +++ b/src/tint.zig @@ -1,29 +1,29 @@ -//! One colour in, a whole palette out. +//! One color in, a whole palette out. //! //! Forty swatches is a fair editor and a poor starting point. Almost nobody //! wants to choose a window backdrop, a sidebar, a pane surface, a pane header //! and two border weights — they want the app to be *green*, and for the six -//! greys behind the green to be the six greys that go with it. That is what this -//! module is: a base colour, and a rule for everything else. +//! grays behind the green to be the six grays that go with it. That is what this +//! module is: a base color, and a rule for everything else. //! -//! It sits underneath the per-colour overrides rather than replacing them (see -//! `palette.resolve`). Picking a base changes what every unedited colour -//! resolves to; a colour that has been set by hand still wins. So the two ways +//! It sits underneath the per-color overrides rather than replacing them (see +//! `palette.resolve`). Picking a base changes what every unedited color +//! resolves to; a color that has been set by hand still wins. So the two ways //! of theming compose in the order you would want them to — build the thing out -//! of one colour, then go and fix the one swatch you don't like — and neither +//! of one color, then go and fix the one swatch you don't like — and neither //! destroys the other's work. //! -//! **How the rule works.** Every colour is one of three kinds: +//! **How the rule works.** Every color is one of three kinds: //! //! * Most of them are *tinted*: they take the base's hue, a fraction of its -//! colourfulness, and a fixed lightness. This is the structure of the theme — +//! colorfulness, and a fixed lightness. This is the structure of the theme — //! the surfaces stacked from the backdrop up to the pane header, the three -//! weights of text on them. A green base makes them faintly green, a grey -//! base makes them grey, and their spacing never changes, because that +//! weights of text on them. A green base makes them faintly green, a gray +//! base makes them gray, and their spacing never changes, because that //! spacing is what tells a pane header from a pane. -//! * Three of them are the *accent*: the base colour itself, and a lighter and a +//! * Three of them are the *accent*: the base color itself, and a lighter and a //! more muted version of it. Picking `#3a76f0` gets you `#3a76f0` as the -//! accent, which is the only behaviour that makes the colour picker feel like +//! accent, which is the only behavior that makes the color picker feel like //! it did what you asked. //! * The rest are *named* — red, green, "failed", "waiting on you". A red that //! followed the base hue would not be red. These keep their own hue and take @@ -34,7 +34,7 @@ //! scaling it about a midpoint. The difference matters in the light scheme, //! where the surfaces sit hard against white: scaling about a midpoint pushes //! them all through the ceiling and the pane, the pane header and the sidebar -//! come out the same colour. Anchoring at the ends instead means the ramp can +//! come out the same color. Anchoring at the ends instead means the ramp can //! only ever spread apart or draw together, which is the thing the setting //! claims to do. //! @@ -56,7 +56,7 @@ const Rgb = palette.Rgb; const Scheme = palette.Scheme; const Tint = palette.Tint; -/// The colour every unedited swatch in `scheme` resolves to under `tint`. +/// The color every unedited swatch in `scheme` resolves to under `tint`. /// /// Total for every key that doesn't inherit one — which is all of them that /// reach here, since `palette.resolve` follows inheritance before it gets this @@ -70,10 +70,10 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb { const ramp = lightness_ramp.get(scheme); const shade: oklab.Lch = switch (stop.role) { - // Structure: the base's hue, a share of its colourfulness, and a fixed + // Structure: the base's hue, a share of its colorfulness, and a fixed // rung on the ladder. The share is a fraction rather than an absolute - // so that a grey base yields a grey window — there is no floor here - // that would make a deliberately colourless theme come out faintly + // so that a gray base yields a gray window — there is no floor here + // that would make a deliberately colorless theme come out faintly // blue. .tinted => .{ .l = stop.l, @@ -81,9 +81,9 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb { .h = base.h, }, - // The accent is the colour that was picked, moved only as far as it has + // The accent is the color that was picked, moved only as far as it has // to be. The lightness clamp is the whole of that: someone who picks a - // near-black as their base means "make it this colour", not "make the + // near-black as their base means "make it this color", not "make the // one thing that has to stand out invisible", and the two lighter and // darker accents are then offsets from wherever it landed. .accent => .{ @@ -92,10 +92,10 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb { .h = base.h, }, - // A colour that has to stay the colour it is named after keeps its hue + // A color that has to stay the color it is named after keeps its hue // outright, and takes from the base only how loud to be — measured // against the accent the shipped palette was tuned around. The floor - // keeps a grey theme's "failed" marker distinguishable from its + // keeps a gray theme's "failed" marker distinguishable from its // "finished" one; the ceiling keeps a neon base from producing sixteen // more neons. .named => .{ @@ -116,23 +116,23 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb { // ------------------------------------------------------------------------- // Contrast -/// Move a lightness away from — or towards — the ends of its scheme's ramp. +/// Move a lightness away from — or toward — the ends of its scheme's ramp. /// -/// The ramp's own ends are pushed out first, each towards the nearer end of the +/// The ramp's own ends are pushed out first, each toward the nearer end of the /// scale and by the same *share of the room it has left*, and everything in /// between is carried along by the affine map that takes the old ends to the new /// ones. Two things fall out of that shape and both are the point: /// /// * Nothing can be pushed past black or white, so nothing clips. A ramp that /// clipped would not merely stop responding at the top — it would collapse the -/// colours nearest the ceiling into each other, which in the light scheme is +/// colors nearest the ceiling into each other, which in the light scheme is /// the pane, the pane header and the sidebar becoming one flat white. /// * Every gap in the ramp scales by the same factor. Turning contrast up /// separates the pane from the backdrop and the text from the pane by the same /// proportion, rather than doing something dramatic at one end of the ladder /// and nothing at the other. /// -/// `spread` is how much of that a given colour takes. The structural colours +/// `spread` is how much of that a given color takes. The structural colors /// take all of it; an accent or a red takes about half, because past a point /// contrast stops making them starker and starts making them not red. fn stretch(l: f32, contrast: f32, ramp: Ramp, spread: f32) f32 { @@ -149,21 +149,21 @@ fn stretch(l: f32, contrast: f32, ramp: Ramp, spread: f32) f32 { /// becomes bare white. const contrast_reach = 0.5; -/// Contrast drains a little colour out of the structural surfaces as it goes up -/// — a starker theme is a cleaner one — and puts a little back into the colours +/// Contrast drains a little color out of the structural surfaces as it goes up +/// — a starker theme is a cleaner one — and puts a little back into the colors /// whose job is to be seen. const chroma_calm = 0.20; const chroma_lift = 0.12; /// The chroma of the accent both shipped palettes are built around, which is -/// what a `named` colour's intensity is measured against. A base this colourful -/// leaves the status and terminal colours exactly as the tables below name them. +/// what a `named` color's intensity is measured against. A base this colorful +/// leaves the status and terminal colors exactly as the tables below name them. const reference_chroma = 0.1944; /// The two ends of a scheme's lightness ladder, and the band an accent has to /// land in to be legible against it. const Ramp = struct { - /// The lightness of the colour furthest into the background — the window + /// The lightness of the color furthest into the background — the window /// backdrop in the dark scheme, the body text in the light one. lo: f32, @@ -188,7 +188,7 @@ const Role = enum { accent, named, - /// How much of the contrast stretch this kind of colour takes. See + /// How much of the contrast stretch this kind of color takes. See /// `stretch`. fn spread(self: Role) f32 { return switch (self) { @@ -199,20 +199,20 @@ const Role = enum { } }; -/// One colour's recipe in one scheme. What the three numbers mean depends on the +/// One color's recipe in one scheme. What the three numbers mean depends on the /// role, which is why they are named for their role rather than for OKLCh. const Stop = struct { role: Role, /// `tinted` and `named`: the lightness outright. `accent`: an offset from - /// the base colour's own. + /// the base color's own. l: f32, - /// `tinted` and `accent`: a multiple of the base colour's chroma. `named`: - /// a chroma outright, scaled by how colourful the base is. + /// `tinted` and `accent`: a multiple of the base color's chroma. `named`: + /// a chroma outright, scaled by how colorful the base is. c: f32, - /// `named` only: the hue that makes it the colour it is named after. + /// `named` only: the hue that makes it the color it is named after. h: f32 = 0, }; @@ -220,7 +220,7 @@ fn tinted(l: f32, c: f32) Stop { return .{ .role = .tinted, .l = l, .c = c }; } -/// `dl` is measured from the base colour's lightness, so that the three accents +/// `dl` is measured from the base color's lightness, so that the three accents /// stay the same distance apart wherever the base sits. fn accent(dl: f32, c: f32) Stop { return .{ .role = .accent, .l = dl, .c = c }; @@ -234,7 +234,7 @@ const Stops = std.enums.EnumArray(Key, ?Stop); /// Named because it has to be typed as an *inner* null: an outer one would mean /// "this table has no stop for anything". A key absent from both tables is one -/// that inherits its colour from another and never reaches this module. +/// that inherits its color from another and never reaches this module. const inherited: ?Stop = null; fn stops(scheme: Scheme) *const Stops { @@ -278,9 +278,9 @@ const dark_stops: Stops = .initDefault(inherited, .{ // the mid one, for the reason `palette.Key.hint` gives. .term_cursor = accent(0.0742, 0.797), - // The four neutral ANSI colours are tinted rather than named: "black" and - // "white" here are the ends of the terminal's own greyscale, and a terminal - // whose greys are a different grey from the window around it looks like a + // The four neutral ANSI colors are tinted rather than named: "black" and + // "white" here are the ends of the terminal's own grayscale, and a terminal + // whose grays are a different gray from the window around it looks like a // pane that failed to load. .ansi_black = tinted(0.2380, 0.190), .ansi_red = named(0.6308, 0.150, 21.4), @@ -353,7 +353,7 @@ const light_stops: Stops = .initDefault(inherited, .{ // A key has a stop in both tables or in neither, and which it is has to be the // same answer `palette.Key.inherits` gives — otherwise a generated palette -// either has a hole in it that `derive` walks into, or quietly stops honouring +// either has a hole in it that `derive` walks into, or quietly stops honoring // an inheritance the rest of the app is written against. comptime { for (std.enums.values(Key)) |key| { @@ -370,7 +370,7 @@ comptime { const testing = std.testing; /// The largest difference in any channel, which is the useful measure here: a -/// generated colour is right if you cannot tell it from the one it is standing +/// generated color is right if you cannot tell it from the one it is standing /// in for, and a couple of steps in one channel is well inside that. fn drift(a: Rgb, b: Rgb) u16 { return @max( @@ -395,7 +395,7 @@ test "the accent regenerates the palette it was taken from" { const generated = derive(key, scheme, tint); const shipped = key.default(scheme); - // Wider for the ANSI colours: those stops are a deliberate retune + // Wider for the ANSI colors: those stops are a deliberate retune // rather than a transcription — libghostty-vt's dark sixteen are // washed out beside a full-strength accent, and the shipped light // cyan is duller than the rest of its row. @@ -412,8 +412,8 @@ test "the accent regenerates the palette it was taken from" { } } -test "the accent is the colour that was picked" { - // Anything else makes the picker feel broken. The only licence taken is the +test "the accent is the color that was picked" { + // Anything else makes the picker feel broken. The only license taken is the // lightness clamp, and these two sit well inside it. for ([_][]const u8{ "#3a76f0", "#d2691e" }) |text| { const base = Rgb.parse(text).?; @@ -449,13 +449,13 @@ test "the surfaces stay in order, whatever the base and the contrast" { const l = oklab.fromRgb(derive(key, scheme, tint)).l; // Light steps *down* from its pane surface for the two - // raised colours, so it is the size of each step that has to + // raised colors, so it is the size of each step that has to // hold rather than the direction of all of them. // // The floor is low because the tightest step in the shipped // dark palette — the sidebar against the pane beside it — is // itself only 0.014, those two being separated as much by - // the gutter between them as by their colours. Softening the + // the gutter between them as by their colors. Softening the // contrast draws that to about 0.012, which is the number // this has to sit under. What it is really watching for is a // step going to nothing. @@ -503,7 +503,7 @@ test "contrast spreads the ramp without pushing anything off the end" { try testing.expect(soft < level); try testing.expect(level < stark); - // And the colours nearest the ceiling stay distinct rather than piling + // And the colors nearest the ceiling stay distinct rather than piling // up against it, which is what a ramp that scaled about its middle would // do to the light scheme. const tint: Tint = .{ .base = base, .contrast = 1 }; @@ -518,11 +518,11 @@ fn span(scheme: Scheme, tint: Tint) f32 { oklab.fromRgb(derive(.bg, scheme, tint)).l); } -test "a grey base makes a grey theme, and keeps the status colours apart" { +test "a gray base makes a gray theme, and keeps the status colors apart" { const tint: Tint = .{ .base = Rgb.parse("#808080").? }; for (std.enums.values(Scheme)) |scheme| { - // No floor sneaking colour back into a theme that asked for none. + // No floor sneaking color back into a theme that asked for none. for ([_]Key{ .bg, .surface, .text, .accent }) |key| { try testing.expect(oklab.fromRgb(derive(key, scheme, tint)).c < 0.01); } diff --git a/tools/gen-emoji.py b/tools/gen-emoji.py index 3416da6..5ad7083 100755 --- a/tools/gen-emoji.py +++ b/tools/gen-emoji.py @@ -26,7 +26,7 @@ just over half of those are the same gesture five more times; a grid of them is harder to look through, not more complete. Every base glyph is present, which is the same choice GTK's own emoji chooser, iOS and Slack all make. -So is anything newer than MAX_VERSION. Colour emoji fonts trail Unicode by a +So is anything newer than MAX_VERSION. Color emoji fonts trail Unicode by a year or two, and a glyph the font has never heard of draws as a hex-digit box — which in a picker reads as a bug rather than as a font that needs updating. At the time of writing, Noto Color Emoji could draw all but 7 of the E17.0 @@ -50,7 +50,7 @@ EMOJI_TEST = "https://unicode.org/Public/emoji/latest/emoji-test.txt" ANNOTATIONS = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotations/en.xml" DERIVED = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotationsDerived/en.xml" -# Terms no data file will ever give you: what a glyph means to someone labelling +# Terms no data file will ever give you: what a glyph means to someone labeling # a terminal tab. CLDR knows a rocket is a "space ship"; it does not know it is # what you reach for when the tab is a deploy. SYNONYMS = { @@ -305,7 +305,7 @@ HEADER = '''//! Every emoji a tab can wear in place of its icon, and the search //! Unicode lists nearly twice this many fully-qualified sequences and the //! difference is almost entirely the same gesture in five tones, which makes a //! grid longer to look through without making it more complete. The version -//! cutoff is there because colour emoji fonts trail Unicode, and a glyph the +//! cutoff is there because color emoji fonts trail Unicode, and a glyph the //! font has never heard of draws as a hex-digit box; the generator's header //! explains how to raise it. //! diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 281f3d2..371bc9d 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,6 +1,6 @@ diff --git a/web/src/App.tsx b/web/src/App.tsx index bbe19a1..71df920 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -213,7 +213,7 @@ export default function App() { // The parsed diff lives here rather than in DiffView because the comments rail // needs it too: deciding which comments the diff can still place is one - // judgement, made once, so the rail and the diff can't disagree about it. + // judgment, made once, so the rail and the diff can't disagree about it. const parsedFiles = useMemo(() => (payload ? parseDiff(payload.patch) : []), [payload]); // What each file's diff currently says, digested. Viewed marks are stored @@ -395,20 +395,20 @@ export default function App() { useEffect(() => { if (!path || !revision || stale) return; - let cancelled = false; + let canceled = false; let busy = false; const check = async () => { // A hidden pane is a pane nobody is reading. It gets checked the moment it // comes back instead, which is when the answer matters. - if (cancelled || busy || document.hidden) return; + if (canceled || busy || document.hidden) return; busy = true; const seq = reqRef.current; try { const { revision: now } = await api.revision(ctx, { ignoreWhitespace: ignoreWs }); // A load that started while this was in flight has already answered the // question, with a revision this closure doesn't know about. - if (cancelled || seq !== reqRef.current) return; + if (canceled || seq !== reqRef.current) return; if (now && now !== revision) setStale(now); } catch { // A failed poll says nothing about the diff — the next one will. @@ -424,7 +424,7 @@ export default function App() { document.addEventListener('visibilitychange', onVisible); window.addEventListener('focus', onVisible); return () => { - cancelled = true; + canceled = true; window.clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onVisible); diff --git a/web/src/components/CommentThread.tsx b/web/src/components/CommentThread.tsx index 1adcedd..4b34e00 100644 --- a/web/src/components/CommentThread.tsx +++ b/web/src/components/CommentThread.tsx @@ -228,7 +228,7 @@ function SingleThread({ } // BodyEditor edits a comment's text in place of its rendered body. It starts -// from the saved text and only reports a change on save, so cancelling always +// from the saved text and only reports a change on save, so canceling always // leaves the stored comment untouched. function BodyEditor({ initial, diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index 542f00b..74b3f78 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -206,12 +206,12 @@ function FileView({ setOldSource(null); return; } - let cancelled = false; + let canceled = false; api.fileContent(base, file.oldPath).then((s) => { - if (!cancelled) setOldSource(s); + if (!canceled) setOldSource(s); }); return () => { - cancelled = true; + canceled = true; }; }, [base, file.oldPath, file.type]); @@ -227,14 +227,14 @@ function FileView({ // Highlighting is done over the *whole* file, never over the visible hunks // alone. Prism is a stateful tokenizer: a construct that opens above the first // visible line — a block comment, a template literal, a heredoc — leaves it in - // the wrong state and mis-colours everything after it, so what got highlighted + // the wrong state and mis-colors everything after it, so what got highlighted // would depend on which context happened to be collapsed. Handing it the base // source (react-diff-view derives the head side by applying `hunks`) makes the // result identical no matter what is expanded. // // A wholly added or deleted file needs no base source: its hunks already carry // every line, so tokenizing them is exact. Otherwise we wait for the fetch - // rather than highlight a fragment — a beat of plain text beats wrong colours. + // rather than highlight a fragment — a beat of plain text beats wrong colors. const tokens: HunkTokens | undefined = useMemo(() => { const lang = languageForFile(path); if (!lang) return undefined; diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx index 3b58cd6..b87294f 100644 --- a/web/src/components/Icon.tsx +++ b/web/src/components/Icon.tsx @@ -5,8 +5,8 @@ // pulled in as a dependency because we need a dozen of ~600, and a local table // keeps the icon set visible in one place instead of hidden behind imports. // -// Every glyph is authored on a 16×16 grid with `fill: currentColor`, so colour -// comes from the surrounding text colour and size from the `size` prop. +// Every glyph is authored on a 16×16 grid with `fill: currentColor`, so color +// comes from the surrounding text color and size from the `size` prop. const PATHS = { 'chevron-down': diff --git a/web/src/styles.css b/web/src/styles.css index fed1b06..4828ce7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,7 +1,7 @@ /* ============================================================================ review — local code review UI - The styling is GitHub's, not an interpretation of it. Every colour below is a + The styling is GitHub's, not an interpretation of it. Every color below is a Primer token: the dark block is Primer's `dark dimmed` theme and the light block is `light`, both at the values GitHub currently ships. The diff geometry — 12px/24px mono rows, 45px line-number columns, a 22px marker @@ -63,7 +63,7 @@ --attention-soft: rgba(174, 124, 20, 0.15); --attention-muted: rgba(174, 124, 20, 0.4); - /* Diff blob colours, sampled from a rendered diff. */ + /* Diff blob colors, sampled from a rendered diff. */ --add-bg: #263834; /* addition, code cell */ --add-gutter: #31503d; /* addition, line number cell */ --add-word: #31583c; /* addition, word-level mark */ @@ -1153,7 +1153,7 @@ body.is-resizing { border-left-color: var(--claude); opacity: 0.6; } -/* An outdated card keeps its status colour on the pill but takes the attention +/* An outdated card keeps its status color on the pill but takes the attention edge, since "can't find the code any more" is what you need to notice first. */ .comment-card.is-outdated { border-left-color: var(--attention); @@ -1251,7 +1251,7 @@ body.is-resizing { } /* What stands in for a diff too large to render, once its warning has been - dismissed — the same centred treatment as .loading, plus the way back in. */ + dismissed — the same centered treatment as .loading, plus the way back in. */ .oversize-notice { max-width: 560px; margin: 64px auto; @@ -1370,7 +1370,7 @@ body.is-resizing { font-family: var(--font-mono); font-size: 12px; } -/* Primer Label: 999px pill, coloured border, 12px text. */ +/* Primer Label: 999px pill, colored border, 12px text. */ .file-status { font-size: 12px; line-height: 18px; @@ -1468,7 +1468,7 @@ body.is-resizing { color: var(--muted); } -/* A viewed file steps back: its header greys out so the ones still to read are +/* A viewed file steps back: its header grays out so the ones still to read are what the eye lands on. */ .file.is-viewed { border-color: var(--border-muted); @@ -1529,7 +1529,7 @@ body.is-resizing { } /* ---- Outdated comments -------------------------------------------------- */ -/* Attention yellow, the same colour the gutter uses for a commented line: the +/* Attention yellow, the same color the gutter uses for a commented line: the comment is intact, only its anchor is gone. Nothing here reads as an error. */ .outdated-note { padding: 12px 16px; @@ -1719,7 +1719,7 @@ body.is-resizing { } /* A range being dragged, or one a pending comment is attached to. GitHub tints - commented lines with attention yellow rather than the accent colour. */ + commented lines with attention yellow rather than the accent color. */ .diff-gutter-selected { background: var(--attention-muted); color: var(--text); @@ -1812,7 +1812,7 @@ body.is-resizing { } /* ---- Primer prettylights syntax theme ----------------------------------- */ -/* Prism's token names mapped onto GitHub's syntax colours. Both places code is +/* Prism's token names mapped onto GitHub's syntax colors. Both places code is highlighted share them: the diff, and a fenced block in a comment. */ :is(.diff, .md) .token.comment, :is(.diff, .md) .token.prolog,