Add notifications.

This commit is contained in:
Greyson Parrelli
2026-08-26 23:24:14 -04:00
parent afad6a2636
commit 7414c89fe6
33 changed files with 1026 additions and 381 deletions
+2 -2
View File
@@ -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. leaves state half-updated, panics on input the caller controls.
3. **Resources and lifetimes** — leaks, missing close/cancel, work that outlives 3. **Resources and lifetimes** — leaks, missing close/cancel, work that outlives
what it belongs to. 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. take, deadlock ordering.
5. **Interface and contract** — a caller that can now be silently wrong; a 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 6. **Tests** — a new branch with real failure modes and no test; a test that
would pass with the bug still in. would pass with the bug still in.
7. **Fit** — code that ignores an existing helper, layering, or naming pattern 7. **Fit** — code that ignores an existing helper, layering, or naming pattern
+84 -35
View File
@@ -116,7 +116,7 @@ just the VT core.
``` ```
main.zig AdwApplication startup 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 Window.zig sidebar + GtkStack of views, tab management, reordering, shortcuts
View.zig one tab's content: its panes, their layout, and drag handling View.zig one tab's content: its panes, their layout, and drag handling
Layout.zig the split tree: nodes, rearranging, GtkPaned materialization 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 Pty.zig openpt/fork/exec, controlling terminal setup
script.zig runs a command for `$(...)` in a layout's directory script.zig runs a command for `$(...)` in a layout's directory
key.zig GDK keyval -> libghostty-vt key mapping 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 theme.zig the palette resolved for the terminal renderer, which reads numbers
Settings.zig preferences: theme, palette overrides, startup tabs, JSON on disk Settings.zig preferences: theme, palette overrides, startup tabs, JSON on disk
SettingsDialog.zig the settings page, including the startup list editor 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 TabSettingsDialog.zig one tab's own settings, and the emoji picker
emoji.zig generated: every emoji the picker offers, and the search over them 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 **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 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 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 moves, so the rest of the tree is unchanged and re-inserting it beside its
original sibling restores the original shape. 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. 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 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 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 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. 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" } { "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 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 startup list asks for its values the same way, so a directory parameter gets the
same field there. 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 **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 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. — leaning on the key never costs the window.
Two closes don't ask, because both are already an answer: accepting the dialog, 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 - **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 it is working, waiting on you, or finished and still unanswered. See
[Agent status](#agent-status) [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 - **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 its right-click menu. A typed name pins the label; clearing it hands the
label back to the panes 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 - **Light and dark schemes**, following the desktop by default and pinnable
from the settings page, applying to open tabs immediately — terminal palette from the settings page, applying to open tabs immediately — terminal palette
included. See [Theme](#theme) included. See [Theme](#theme)
- **Every colour in the palette editable**, per scheme, from **Settings → - **Every color in the palette editable**, per scheme, from **Settings →
Appearance → Customise**: the surfaces, the text on them, the accents, the Appearance → Customize**: the surfaces, the text on them, the accents, the
status colours, and the terminal's own background, cursor, selection and 16 status colors, and the terminal's own background, cursor, selection and 16
ANSI colours. Each change repaints as you make it. See ANSI colors. Each change repaints as you make it. See
[Customising the palette](#customising-the-palette) [Customizing the palette](#customizing-the-palette)
### Shortcuts ### 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 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 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 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, 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 which in a picker reads as a bug; `--max-version` raises the cutoff once fonts
have caught up. have caught up.
@@ -923,11 +929,11 @@ So a tab can carry a dot:
| red | stopped on an error | | red | stopped on an error |
| none | idle | | 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 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 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 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. 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 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 the one asking; the row stays lit until every finished pane in it has been
answered. 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 ### How it gets there
Programs report state with **OSC 9;4** — the ConEmu progress protocol, the same 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 **Settings**, whose Appearance section holds the choice: light, dark, or
**system**, which is the default and follows the desktop. **system**, which is the default and follows the desktop.
Every colour it resolves to can be changed — see Every color it resolves to can be changed — see
[Customising the palette](#customising-the-palette). [Customizing the palette](#customizing-the-palette).
The preference lives in `~/.config/playpen/settings.json` (or The preference lives in `~/.config/playpen/settings.json` (or
`$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the `$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the
@@ -1061,6 +1109,7 @@ The preference lives in `~/.config/playpen/settings.json` (or
"version": 1, "version": 1,
"theme": "system", "theme": "system",
"confirm_quit": true, "confirm_quit": true,
"notifications": true,
"colors": {}, "colors": {},
"startup": [] "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 Switching applies immediately to every open tab — nothing needs restarting, and
a shell that has been running all day repaints along with everything else. 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: `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 dialog chrome. It is told to force a scheme, or left on `default` to follow
the desktop. the desktop.
- **`style.css`** colours everything the app draws itself. It is written - **`style.css`** colors everything the app draws itself. It is written
entirely against named colours; `palette.zig` writes the `@define-color` 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 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 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 - **`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 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` 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 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 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 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 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. 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 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 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. 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 **Settings → Appearance → Customize** opens the palette itself: a swatch for
every colour named above, in sections — surfaces, text, tab rows, accent, every color named above, in sections — surfaces, text, tab rows, accent,
status, then the terminal's own background, foreground, cursor and selection, 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, 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. 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 Three things about how it behaves, all following from the same decision — that
the editor edits **the palette you can see**: 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 behind the dialog, so the swatch is its own preview; there is no OK button
because there is nothing pending to confirm. because there is nothing pending to confirm.
- **It edits the scheme in force.** Which is why the way to edit the other one is - **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. you the dark palette you built.
- **The terminal follows the window.** Left alone, the terminal's background *is* - **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 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 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 Only what has been changed is stored, per scheme, under `colors` in
`settings.json`: `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 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 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 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. normally.
`palette.zig` is where the names, the defaults and the grouping live, which is `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 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 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. table answers all of it and the CSS is generated from the table.
+18 -1
View File
@@ -150,7 +150,7 @@ pub fn build(b: *std.Build) void {
paths_tests.root_module.addImport("glib", gobject.module("glib2")); paths_tests.root_module.addImport("glib", gobject.module("glib2"));
test_step.dependOn(&b.addRunArtifact(paths_tests).step); 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 // 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 // for the terminal's own default palette and nothing else, so it tests
// without a display in the same way the two above do. // 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); 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(.{ const emoji_tests = b.addTest(.{
.root_module = b.createModule(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/emoji.zig"), .root_source_file = b.path("src/emoji.zig"),
+2 -2
View File
@@ -4,12 +4,12 @@
edge. Shapes are kept large and high-contrast so the sidebar is still 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. 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 `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 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 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 where one icon repaints itself with the desktop theme is a worse result than
one that is simply recognisable. one that is simply recognizable.
--> -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs> <defs>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -315,7 +315,7 @@ fn assemble(node: *Node) void {
} }
/// The side `node` sits on within its parent split, and the parent's ratio. /// 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 } { pub fn positionOf(node: *Node) ?struct { sibling: *Node, side: Side, ratio: f64 } {
const parent = node.parent orelse return null; const parent = node.parent orelse return null;
const s = parent.kind.split; const s = parent.kind.split;
+1 -1
View File
@@ -303,7 +303,7 @@ fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node {
/// A parameter's type, defaulting to a plain string. /// 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 /// 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 /// 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 /// heard of is only a prompt it cannot improve on, and asking for it in a text
+1 -1
View File
@@ -78,7 +78,7 @@ pub fn present(
for (layout.parameters, 0..) |param, i| { for (layout.parameters, 0..) |param, i| {
const label = gtk.Label.new(null); const label = gtk.Label.new(null);
// The description is what the author wrote for a human; the bare name // 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; var label_buf: [128]u8 = undefined;
const text = if (param.description.len > 0) param.description else param.name; const text = if (param.description.len > 0) param.description else param.name;
label.setText(std.fmt.bufPrintZ(&label_buf, "{s}", .{text}) catch "parameter"); label.setText(std.fmt.bufPrintZ(&label_buf, "{s}", .{text}) catch "parameter");
+54 -54
View File
@@ -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. //! is on screen.
//! //!
//! It edits **the palette you can see**, and that is the whole design. Every //! 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 //! color 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 //! 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 //! 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 //! 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 //! answer to "I want to change the light one" is the theme picker above: switch
//! to light, and this section switches with it. //! 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 //! 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 //! 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* //! them. The terminal's colors go one step further: left alone they *follow*
//! the surface and text colours above them (see `palette.Key.inherits`), so //! the surface and text colors above them (see `palette.Key.inherits`), so
//! recolouring the app recolours the terminal drawn on it without asking anyone //! recoloring the app recolors the terminal drawn on it without asking anyone
//! to set the same colour twice — which is also why every row is re-read after //! to set the same color twice — which is also why every row is re-read after
//! any change, not just the row that changed. //! any change, not just the row that changed.
//! //!
//! The swatches start collapsed, and the two controls above them do not. That //! 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 //! *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 //! 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 //! 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. //! corrections someone made to the last one.
const std = @import("std"); const std = @import("std");
@@ -49,7 +49,7 @@ pub const Options = struct {
ctx: ?*anyopaque = null, 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 /// into the ANSI grid — the hex it currently resolves to and the button that
/// puts it back. /// puts it back.
const Row = struct { const Row = struct {
@@ -60,7 +60,7 @@ const Row = struct {
revert: ?*gtk.Button = null, 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. /// the section has nothing to reset.
const GroupHeader = struct { const GroupHeader = struct {
editor: *PaletteEditor, editor: *PaletteEditor,
@@ -81,14 +81,14 @@ body: *gtk.Box,
/// Says which scheme is being edited, and is rewritten when that changes. /// Says which scheme is being edited, and is rewritten when that changes.
scheme_hint: *gtk.Label, 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 /// stands at, and the button that drops it. Assigned in `build`, since the
/// swatch has to be handed its dialog at construction. /// swatch has to be handed its dialog at construction.
base: *gtk.ColorDialogButton, base: *gtk.ColorDialogButton,
base_hex: *gtk.Label, base_hex: *gtk.Label,
base_clear: *gtk.Button, 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. /// there is a base, since on its own it has nothing to spread.
contrast: *gtk.Scale, contrast: *gtk.Scale,
@@ -96,7 +96,7 @@ contrast: *gtk.Scale,
reset: *gtk.Button, reset: *gtk.Button,
/// Indexed by `@intFromEnum(key)`, so a row is found without searching. Inline /// 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 /// count is known at compile time, and the addresses have to be stable because
/// every swatch's callback holds one. /// every swatch's callback holds one.
rows: [palette.count]Row, 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 // The step is what the arrow keys move by; the range is the one
// `palette.Tint.contrast` is defined over. // `palette.Tint.contrast` is defined over.
.contrast = gtk.Scale.newWithRange(.horizontal, -1, 1, 0.05), .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, .rows = undefined,
.groups = undefined, .groups = undefined,
.on_report = opts.on_report, .on_report = opts.on_report,
@@ -160,8 +160,8 @@ pub fn refresh(self: *PaletteEditor) void {
fn build(self: *PaletteEditor) void { fn build(self: *PaletteEditor) void {
self.root.append(self.buildHeader()); self.root.append(self.buildHeader());
// Above the base colour rather than inside the disclosure with the // Above the base color rather than inside the disclosure with the
// swatches, because it is true of the base colour too: both palettes can be // 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 // given one, and this is the only thing on the page that says which of them
// the control below is about to change. // the control below is about to change.
self.scheme_hint.setXalign(0); 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).addCssClass("flat");
self.reset.as(gtk.Widget).setHalign(.start); self.reset.as(gtk.Widget).setHalign(.start);
self.reset.as(gtk.Widget).setTooltipText( self.reset.as(gtk.Widget).setTooltipText(
"Put every colour in this scheme back to the one Playpen ships with, " ++ "Put every color in this scheme back to the one Playpen ships with, " ++
"and drop the base colour above", "and drop the base color above",
); );
_ = gtk.Button.signals.clicked.connect(self.reset, *PaletteEditor, &onReset, self, .{}); _ = gtk.Button.signals.clicked.connect(self.reset, *PaletteEditor, &onReset, self, .{});
self.body.append(self.reset.as(gtk.Widget)); 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"); name.as(gtk.Widget).addCssClass("playpen-dialog-label");
labels.append(name.as(gtk.Widget)); 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.setXalign(0);
sub.setWrap(1); sub.setWrap(1);
sub.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); 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).addCssClass("playpen-settings-choice");
wrap.as(gtk.Widget).setValign(.center); wrap.as(gtk.Widget).setValign(.center);
const toggle = gtk.ToggleButton.newWithLabel("Customise"); const toggle = gtk.ToggleButton.newWithLabel("Customize");
_ = gtk.ToggleButton.signals.toggled.connect( _ = gtk.ToggleButton.signals.toggled.connect(
toggle, toggle,
*PaletteEditor, *PaletteEditor,
@@ -232,7 +232,7 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget {
return row.as(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. /// out of, and how far apart to spread what gets built.
/// ///
/// Outside the disclosure below on purpose. This is the answer for someone who /// 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); const group = gtk.Box.new(.vertical, 6);
// The same class the swatch sections below carry, which is where the // 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 // stylesheet hangs the "a color button is a rectangle of the color, not a
// button holding one" rule. This row has a colour button in it and wants to // button holding one" rule. This row has a color button in it and wants to
// look like the ones under Customise. // look like the ones under Customize.
group.as(gtk.Widget).addCssClass("playpen-color-group"); group.as(gtk.Widget).addCssClass("playpen-color-group");
// ---- the colour ---- // ---- the color ----
const row = gtk.Box.new(.horizontal, 8); const row = gtk.Box.new(.horizontal, 8);
row.as(gtk.Widget).addCssClass("playpen-color-row"); 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.setXalign(0);
label.as(gtk.Widget).setHexpand(1); label.as(gtk.Widget).setHexpand(1);
label.as(gtk.Widget).addCssClass("playpen-dialog-label"); label.as(gtk.Widget).addCssClass("playpen-dialog-label");
@@ -265,7 +265,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
const dialog = gtk.ColorDialog.new(); const dialog = gtk.ColorDialog.new();
dialog.setWithAlpha(0); dialog.setWithAlpha(0);
dialog.setModal(1); dialog.setModal(1);
dialog.setTitle("Base colour"); dialog.setTitle("Base color");
self.base = gtk.ColorDialogButton.new(dialog); self.base = gtk.ColorDialogButton.new(dialog);
self.base.as(gtk.Widget).setValign(.center); self.base.as(gtk.Widget).setValign(.center);
@@ -280,7 +280,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
row.append(self.base.as(gtk.Widget)); row.append(self.base.as(gtk.Widget));
self.base_clear.as(gtk.Widget).addCssClass("flat"); 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( _ = gtk.Button.signals.clicked.connect(
self.base_clear, self.base_clear,
*PaletteEditor, *PaletteEditor,
@@ -293,15 +293,15 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
group.append(row.as(gtk.Widget)); group.append(row.as(gtk.Widget));
group.append(hint( group.append(hint(
"Pick one and the rest follows: a very dark version of it behind the window, " ++ "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. " ++ "lighter ones for the panes and the text on them, and the color itself as the accent. " ++
"Anything you set under Customise stays where you put it.", "Anything you set under Customize stays where you put it.",
)); ));
// ---- how far apart ---- // ---- how far apart ----
const contrast_row = gtk.Box.new(.horizontal, 8); const contrast_row = gtk.Box.new(.horizontal, 8);
contrast_row.as(gtk.Widget).addCssClass("playpen-color-row"); contrast_row.as(gtk.Widget).addCssClass("playpen-color-row");
contrast_row.as(gtk.Widget).setTooltipText( 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.", "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).setIncrements(0.05, 0.25);
self.contrast.as(gtk.Range).setRoundDigits(2); 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. // again after a drag: GTK snaps the handle to a mark it passes near.
self.contrast.addMark(-1, .bottom, "Soft"); self.contrast.addMark(-1, .bottom, "Soft");
self.contrast.addMark(0, .bottom, null); self.contrast.addMark(0, .bottom, null);
@@ -350,8 +350,8 @@ fn buildGroups(self: *PaletteEditor) void {
var current: ?palette.Group = null; var current: ?palette.Group = null;
var section: *gtk.Box = undefined; var section: *gtk.Box = undefined;
// The ANSI colours are a grid of bare swatches rather than sixteen labelled // The ANSI colors are a grid of bare swatches rather than sixteen labeled
// rows: they are a palette people recognise by position — eight normal, then // 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. // eight bright — and a list of their names says less than the strip does.
var ansi: *gtk.Grid = undefined; 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"); const reset = gtk.Button.newFromIconName("edit-undo-symbolic");
reset.as(gtk.Widget).addCssClass("flat"); 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)]; const header = &self.groups[@intFromEnum(group)];
header.* = .{ .editor = self, .group = group, .reset = reset }; 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); 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. /// for changing that.
fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget { fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget {
const row = gtk.Box.new(.horizontal, 8); 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"); label.as(gtk.Widget).addCssClass("playpen-dialog-label");
row.append(label.as(gtk.Widget)); 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 // the settings file, since anyone hand-editing that file is looking at this
// list to find out what to call things. // list to find out what to call things.
var tip: [256]u8 = undefined; 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. /// A swatch, and the row record that goes with it.
/// ///
/// Alpha is off: `style.css` derives every wash it needs from `alpha()` on the /// 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 /// 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 honour. /// of saying the same thing, and one the terminal renderer could not honor.
fn newSwatch( fn newSwatch(
self: *PaletteEditor, self: *PaletteEditor,
key: palette.Key, 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 // 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 // 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| const base = if (changes.tint) |tint|
tint.base tint.base
else else
@@ -524,9 +524,9 @@ fn reload(self: *PaletteEditor) void {
self.contrast.as(gtk.Widget).setSensitive(@intFromBool(changes.tint != null)); self.contrast.as(gtk.Widget).setSensitive(@intFromBool(changes.tint != null));
for (&self.rows) |*row| { for (&self.rows) |*row| {
// The colour, not the override: a terminal colour that is following the // The color, not the override: a terminal color that is following the
// surface above it, or any colour at all under a base, has to show the // surface above it, or any color at all under a base, has to show the
// colour it is actually painted in. // color it is actually painted in.
const color = palette.resolve(row.key, scheme, changes); const color = palette.resolve(row.key, scheme, changes);
var rgba = toRgba(color); var rgba = toRgba(color);
@@ -538,7 +538,7 @@ fn reload(self: *PaletteEditor) void {
} }
// Whether there is anything to revert *to*, which is a different // 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. // that has been set.
if (row.revert) |button| { if (row.revert) |button| {
button.as(gtk.Widget).setSensitive(@intFromBool(changes.overrides.get(row.key) != null)); 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 /// 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 /// 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 { fn applied(self: *PaletteEditor) void {
appearance.refresh(); appearance.refresh();
self.reload(); self.reload();
@@ -580,7 +580,7 @@ fn applied(self: *PaletteEditor) void {
/// Show the change and leave the file for later. /// Show the change and leave the file for later.
/// ///
/// For the contrast slider alone. Every other control here settles on a value /// 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 /// 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 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 /// 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 // 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 // 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 // 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. // on screen.
const scheme = theme.currentScheme(); const scheme = theme.currentScheme();
Settings.get().colors.of(scheme).overrides.set(row.key, fromRgba(swatch.getRgba())); 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 { 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 // 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; Settings.get().colors.of(theme.currentScheme()).tint = null;
self.applied(); 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 /// 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 /// 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 { fn fromRgba(rgba: *const gdk.RGBA) palette.Rgb {
return .{ return .{
.r = channel(rgba.f_red), .r = channel(rgba.f_red),
+10 -10
View File
@@ -136,7 +136,7 @@ const all_sides = [_]Side{ .left, .right, .top, .bottom };
// Status dots // Status dots
// //
// Both the pane header and the sidebar row show the same small filled circle, // 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 // property, so the state is carried entirely by which class is applied and
// there is no second icon to keep in sync. // 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. /// 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 /// Hiding rather than clearing the color matters: an idle pane should show
/// nothing at all, not a dot in the background colour that still takes up /// 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. /// space and still catches the eye when the row is hovered.
pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void { pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void {
const w = dot.as(gtk.Widget); 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. /// cannot know about on its own.
/// ///
/// `done` is the whole reason this is a separate type from `Status`. Content /// `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. /// carrying it — a sidebar row, or a pane's frame.
/// ///
/// Returned as a pair so the two can never drift into disagreeing about /// 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 { pub fn classes(self: Attention) ?struct {
dot: [:0]const u8, dot: [:0]const u8,
surface: [:0]const u8, surface: [:0]const u8,
@@ -255,8 +255,8 @@ pub const attention_classes = [_][:0]const u8{
"playpen-attn-failed", "playpen-attn-failed",
}; };
/// Bring a dot in line with what it should be signalling. The tooltip comes /// Bring a dot in line with what it should be signaling. The tooltip comes
/// along with the colour, since a colour on its own doesn't say what it means. /// 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 { pub fn applyDot(dot: *gtk.Image, state: Attention) void {
setDot(dot, state.class()); setDot(dot, state.class());
dot.as(gtk.Widget).setTooltipText(state.tooltip()); 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 /// 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 /// 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 /// 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. /// your eye.
pub fn applyAttention(surface: *gtk.Widget, dot: *gtk.Image, state: Attention) void { pub fn applyAttention(surface: *gtk.Widget, dot: *gtk.Image, state: Attention) void {
applyDot(dot, state); applyDot(dot, state);
@@ -435,7 +435,7 @@ pub fn titleSlice(self: *const Pane) [:0]const u8 {
return std.mem.sliceTo(&self.title, 0); 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 { pub fn attention(self: *const Pane) Attention {
return .of(self.status, self.done_unanswered); 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. /// 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 /// 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 /// 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 /// 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 /// the bottom of the window gives a pane stretching across the bottom, while
+2 -2
View File
@@ -12,7 +12,7 @@
//! the matches agree, the arrow keys pick out of the list, and `Enter` takes //! 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 //! the highlighted one or, with nothing highlighted, means what it means
//! everywhere else in the dialog. `GtkEntryCompletion` would have been the //! 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 //! 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 //! common prefix, which in a directory of siblings guesses wrong more often
//! than it helps. //! than it helps.
@@ -455,7 +455,7 @@ fn onFolderChosen(
var err: ?*glib.Error = null; var err: ?*glib.Error = null;
const file = dialog.selectFolderFinish(result, &err) orelse { const file = dialog.selectFolderFinish(result, &err) orelse {
// Dismissing the chooser is how most trips through it end, and // 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. // is worth a word.
if (err) |e| e.free(); if (err) |e| e.free();
return; return;
+1 -1
View File
@@ -11,7 +11,7 @@
//! //!
//! The tree is allocated in the layout store's arena in both cases, so saving //! 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 //! 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 std = @import("std");
const gio = @import("gio"); const gio = @import("gio");
+2 -2
View File
@@ -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 /// 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. /// what this actually is — the answer matters less than there being one.
fn effectDeviceAttributes( fn effectDeviceAttributes(
_: *vt.TerminalStream.Handler, _: *vt.TerminalStream.Handler,
@@ -326,7 +326,7 @@ fn effectDeviceAttributes(
} }
/// Effect callback: the child asked whether it is drawing on a light or dark /// 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. /// the scheme the grid is actually painted in.
fn effectColorScheme( fn effectColorScheme(
_: *vt.TerminalStream.Handler, _: *vt.TerminalStream.Handler,
+69 -27
View File
@@ -4,8 +4,8 @@
//! something changes. It carries a version from the start, so adding the next //! something changes. It carries a version from the start, so adding the next
//! setting is not also the day we invent a format. //! setting is not also the day we invent a format.
//! //!
//! The settings are three different shapes. The colour scheme is one word. The //! The settings are three different shapes. The color scheme is one word. The
//! **palette changes** are a base colour per scheme, plus a colour per name for //! **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 //! the names someone has gone on to change by hand. The **startup tabs** are a
//! list: which //! list: which
//! saved layout to open, what to fill its parameters in with, and what to call //! saved layout to open, what to fill its parameters in with, and what to call
@@ -15,9 +15,9 @@
//! field. //! field.
//! //!
//! The palette changes deliberately stay out of that arena. They are a base //! 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 //! 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 //! 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 //! **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 //! 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 /// What someone has done to the palette, per scheme: the color it is generated
/// from, and the individual colours they have set by hand. /// from, and the individual colors they have set by hand.
/// ///
/// Both schemes are kept, not just the one in force: the editor changes the /// 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 /// 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. /// a mistyped chord and every shell in every tab exiting.
confirm_quit: bool = true, confirm_quit: bool = true,
/// Changes to the palette. Empty means every colour is at its default, which is /// Whether a pane finishing posts a desktop notification.
/// the state anyone who never opens the colour editor stays in — and it is the ///
/// 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. /// reason the defaults can be retuned in a later version and still reach them.
colors: Colors = .{}, colors: Colors = .{},
@@ -183,7 +192,7 @@ pub fn deinit() void {
/// Read the settings file, falling back to defaults for anything missing. /// 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 /// 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 /// 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 /// 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 (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("colors")) |value| self.parseColors(value);
if (root.get("tint")) |value| self.parseTint(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": {…}}`. /// Read the palette overrides: `{"dark": {"accent": "#ff8800"}, "light": {…}}`.
/// ///
/// Nothing here can fail the file. A colour is one word of a palette, and the /// Nothing here can fail the file. A color 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 — /// 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 /// 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. /// newer version and a typo in a hand-edited file cost the same nothing.
fn parseColors(self: *Settings, raw: std.json.Value) void { 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(); var it = colors.iterator();
while (it.next()) |kv| { while (it.next()) |kv| {
const key = std.meta.stringToEnum(palette.Key, kv.key_ptr.*) orelse { 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; continue;
}; };
@@ -313,7 +326,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
}; };
const color = palette.Rgb.parse(text) orelse { 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; continue;
}; };
@@ -325,7 +338,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
/// Read the generated palettes: `{"dark": {"base": "#3a76f0", "contrast": 0.5}}`. /// Read the generated palettes: `{"dark": {"base": "#3a76f0", "contrast": 0.5}}`.
/// ///
/// A scheme without a `base` has no generated palette at all, whatever else the /// 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 /// 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 /// contrast costs only the contrast: the base is the setting, and dropping the
/// whole theme over one bad number would be the wrong trade. /// 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 { 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; 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: /// 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. /// 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 /// 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 /// binding and is logged; what it falls back to is the shipped default, which is
/// exactly what an absent entry already means. /// 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.write(@tagName(self.theme));
try json.objectField("confirm_quit"); try json.objectField("confirm_quit");
try json.write(self.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 // 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 // the startup list below is: a file you have opened to see what you can put
// in it should name what it accepts. // in it should name what it accepts.
@@ -636,7 +651,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
} }
try json.endObject(); 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 // 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 // because that is the order they apply in, and a settings file is read by
// people as well as by this. // people as well as by this.
@@ -657,7 +672,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
try json.endObject(); try json.endObject();
// Only the shortcuts that have been rebound, for the same reasons as the // 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 // 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. // 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 // 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 // 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 // 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 // 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. // 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); 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" { test "a confirm_quit we can't read leaves the confirmation up" {
var settings = forTesting(); var settings = forTesting();
defer settings.arena.deinit(); 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("#112233").?, settings.colors.dark.overrides.get(.ansi_red).?);
try std.testing.expectEqual(palette.Rgb.parse("#fafafa").?, settings.colors.light.overrides.get(.sidebar).?); 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. // 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.light.overrides.get(.accent));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar)); 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(); var settings = forTesting();
defer settings.arena.deinit(); defer settings.arena.deinit();
try settings.parse( try settings.parse(
\\{"theme": "dark", "colors": {"dark": { \\{"theme": "dark", "colors": {"dark": {
\\ "not_a_colour": "#123456", \\ "not_a_color": "#123456",
\\ "border": "rebeccapurple", \\ "border": "rebeccapurple",
\\ "sidebar": 12, \\ "sidebar": 12,
\\ "accent": "#ff8800" \\ "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(.border));
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar)); 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 // The whole file survives, including the theme above the colors and the
// one colour in the list that was well formed. // one color in the list that was well formed.
try std.testing.expectEqual(Theme.dark, settings.theme); try std.testing.expectEqual(Theme.dark, settings.theme);
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.overrides.get(.accent).?); 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); 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(); var settings = forTesting();
defer settings.arena.deinit(); 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(); var settings = forTesting();
defer settings.arena.deinit(); defer settings.arena.deinit();
+77 -3
View File
@@ -109,6 +109,9 @@ colors: *PaletteEditor,
/// one place and read in another; nothing re-checks it. /// one place and read in another; nothing re-checks it.
confirm_quit: *gtk.Switch, 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 /// Container the startup rows live in, so rows can be added and removed after
/// the page is already on screen. /// the page is already on screen.
startup_box: *gtk.Box, startup_box: *gtk.Box,
@@ -145,6 +148,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo
.theme_buttons = undefined, .theme_buttons = undefined,
.colors = undefined, .colors = undefined,
.confirm_quit = gtk.Switch.new(), .confirm_quit = gtk.Switch.new(),
.notifications = gtk.Switch.new(),
.startup_box = gtk.Box.new(.vertical, 6), .startup_box = gtk.Box.new(.vertical, 6),
.error_label = gtk.Label.new(null), .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.as(gtk.Widget).addCssClass("playpen-dialog-content");
content.append(self.buildAppearance()); content.append(self.buildAppearance());
content.append(self.buildNotifications());
content.append(self.buildQuitting()); content.append(self.buildQuitting());
content.append(self.buildStartup()); content.append(self.buildStartup());
@@ -219,7 +224,7 @@ pub fn close() void {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Appearance // 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 { fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
const group = gtk.Box.new(.vertical, 10); const group = gtk.Box.new(.vertical, 10);
group.as(gtk.Widget).addCssClass("playpen-settings-group"); 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. /// 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 /// 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 /// 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 /// would apply whichever scheme happened to be next in the list. Handling the
/// exclusivity here instead makes each click exactly one decision. /// 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]); appearance.setTheme(values[index]);
// The palette editor edits whichever scheme is on screen, and that is what // 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(); self.colors.refresh();
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Quitting // 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. /// The Quitting section: whether closing the window asks first.
/// ///
/// Above the startup list rather than below it. That list grows with the number /// Above the startup list rather than below it. That list grows with the number
+1 -1
View File
@@ -712,7 +712,7 @@ fn selectPress(self: *Terminal, x: f64, y: f64) void {
const term = &self.session.term; const term = &self.session.term;
const pin = self.pinAt(x, y) orelse return; 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. // cell, two by word, three by line.
const sel = self.gesture.press(term, .{ const sel = self.gesture.press(term, .{
.time = now(), .time = now(),
+16 -12
View File
@@ -3,7 +3,7 @@
//! //!
//! Dragging a pane rearranges the view live rather than on release. Each time //! 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 //! 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 //! 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 //! 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. //! re-inserting it next to its original sibling restores the original shape.
@@ -40,7 +40,7 @@ pub const Target = struct {
const Drag = struct { const Drag = struct {
pane: *Pane, 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. /// it back.
origin_sibling: *Layout.Node, origin_sibling: *Layout.Node,
origin_side: Side, origin_side: Side,
@@ -50,7 +50,7 @@ const Drag = struct {
previewed: ?Target = null, previewed: ?Target = null,
/// Set once a drop has been accepted; a drag that ends without this was /// 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, 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 /// last here" cannot be recovered by looking at the panes afterwards, since
/// one that finished before your last visit looks identical to one that /// one that finished before your last visit looks identical to one that
/// finished after it. /// 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. /// Something in here asked for a review pane.
/// ///
@@ -115,7 +120,7 @@ pub const Callbacks = struct {
on_empty: *const fn (ctx: ?*anyopaque) void, on_empty: *const fn (ctx: ?*anyopaque) void,
on_title: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque) void,
on_status: *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, on_review: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque, ctx: ?*anyopaque,
}; };
@@ -167,7 +172,7 @@ pub fn widget(self: *View) *gtk.Widget {
// arrangement rather than rebuilding an approximation of it. // arrangement rather than rebuilding an approximation of it.
/// Put the view on screen. Every structural change goes through here rather /// 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. /// instead of being re-checked at each call site.
fn render(self: *View) void { fn render(self: *View) void {
if (self.zoomed) |pane| { if (self.zoomed) |pane| {
@@ -418,9 +423,8 @@ pub fn setFocused(self: *View, pane: *Pane) void {
} }
pub fn paneFinished(self: *View, pane: *Pane) void { pub fn paneFinished(self: *View, pane: *Pane) void {
_ = pane;
if (self.closing) return; if (self.closing) return;
self.on_finished(self.ctx); self.on_finished(self.ctx, pane.titleSlice());
} }
pub fn paneTitleChanged(self: *View, pane: *Pane) void { 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 /// 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 { pub fn moveFocused(self: *View, side: Side) void {
const pane = self.focusedPane() orelse return; const pane = self.focusedPane() orelse return;
if (self.panes.items.len < 2) 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. /// 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 /// 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 /// 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, /// 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 // 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 // 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 // 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; if (self.zoomed != null) return false;
const target = self.neighbor(pane, side) orelse 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 // 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 // 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; if (self.zoomed != null) return false;
const node = self.layout.find(pane) orelse return false; const node = self.layout.find(pane) orelse return false;
+127 -21
View File
@@ -28,6 +28,7 @@ const View = @import("View.zig");
const appearance = @import("appearance.zig"); const appearance = @import("appearance.zig");
const emoji = @import("emoji.zig"); const emoji = @import("emoji.zig");
const key = @import("key.zig"); const key = @import("key.zig");
const notify = @import("notify.zig");
const review = @import("review.zig"); const review = @import("review.zig");
const shortcuts = @import("shortcuts.zig"); const shortcuts = @import("shortcuts.zig");
@@ -132,7 +133,7 @@ layout_popover: *gtk.Popover,
/// they belong to are on screen. /// they belong to are on screen.
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty, 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. /// a row and a pane header show the same five states for the same reasons.
const Attention = Pane.Attention; 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 /// 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 /// 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. /// something to undo, which is what `origin` is for.
const Drag = struct { const Drag = struct {
tab: *Tab, 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. /// put it back.
origin: usize, origin: usize,
/// Set once a drop has been accepted; a drag that ends without this was /// 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, committed: bool = false,
}; };
@@ -177,7 +178,7 @@ const Tab = struct {
row: *gtk.ListBoxRow, row: *gtk.ListBoxRow,
label: *gtk.Label, 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. /// the sidebar without reading the title.
icon: *gtk.Image, icon: *gtk.Image,
@@ -228,6 +229,13 @@ const Tab = struct {
/// with me?", which only you can answer. /// with me?", which only you can answer.
finished_since_visit: bool = false, 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: [16]u8,
name_len: usize, 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 // 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: // 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. // for having missed every row by a few pixels.
self.installRowDropTarget(); 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 // 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 // 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. /// 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 { fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void {
_ = self; _ = 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.setHasArrow(0);
tab.menu_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget)); 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)); 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 /// One line of the row menu, styled like the layout menu's rows so the two
/// popovers read as the same kind of thing. /// popovers read as the same kind of thing.
fn menuItem( fn menuItem(
@@ -647,7 +699,7 @@ fn menuItem(
return button.as(gtk.Widget); 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 /// popover per row anchored to the whole row, a fixed position would put the
/// menu somewhere you weren't pointing. /// menu somewhere you weren't pointing.
fn onRowSecondary( fn onRowSecondary(
@@ -663,6 +715,7 @@ fn onRowSecondary(
.f_width = 1, .f_width = 1,
.f_height = 1, .f_height = 1,
}; };
fillRowMenu(tab);
tab.menu_popover.setPointingTo(&at); tab.menu_popover.setPointingTo(&at);
tab.menu_popover.popup(); tab.menu_popover.popup();
} }
@@ -677,6 +730,11 @@ fn onMenuSettings(_: *gtk.Button, tab: *Tab) callconv(.c) void {
tab.window.openTabSettings(tab); tab.window.openTabSettings(tab);
} }
fn onMenuUnmute(_: *gtk.Button, tab: *Tab) callconv(.c) void {
tab.menu_popover.popdown();
tab.mute = .off;
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Per-tab settings // Per-tab settings
@@ -1290,12 +1348,16 @@ fn select(self: *Window, tab: *Tab) void {
self.list.selectRow(tab.row); self.list.selectRow(tab.row);
tab.view.focus(); 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 // 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. // split keeps its own dot until you go to it.
tab.finished_since_visit = false; tab.finished_since_visit = false;
tab.view.answerFocused(); tab.view.answerFocused();
self.refreshStatus(tab); 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 { 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 // 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 // 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; if (self.tabs.items.len < 2) return null;
self.drag = .{ .tab = tab, .origin = self.indexOf(tab) orelse 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(); 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 /// 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 /// 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 /// 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 /// 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 /// 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. /// would make the setting look like it only applies to new tabs.
fn onAppearanceChanged(ctx: ?*anyopaque) void { fn onAppearanceChanged(ctx: ?*anyopaque) void {
const self: *Window = @ptrCast(@alignCast(ctx.?)); 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 /// 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 /// it stop and then gone somewhere else, and the next time you open this tab is
/// when that stops being news. /// when that stops being news.
fn onViewFinished(ctx: ?*anyopaque) void { fn onViewFinished(ctx: ?*anyopaque, task: []const u8) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?)); const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.finished_since_visit = true; 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.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. /// 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. /// repositories reviews the one you are looking at.
/// ///
/// Falls back to playpen's own working directory, which at least gives the /// 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 { fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
_ = self; _ = 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 // 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 // `std.posix`, matching `Pty.zig`: the latter has been churning across Zig
// releases and this is one call. // releases and this is one call.
if (std.c.getcwd(buf.ptr, buf.len) != null) { if (std.c.getcwd(buf.ptr, buf.len) != null) {
+10 -10
View File
@@ -1,14 +1,14 @@
//! Which palette the app is painted with, and the plumbing that keeps three //! 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 //! entries, scrollbars, dialog chrome. It is told to force light or dark, or
//! left on `default` so it follows the desktop. //! left on `default` so it follows the desktop.
//! * **`style.css`** colours everything the app draws for itself. It is //! * **`style.css`** colors everything the app draws for itself. It is
//! written entirely against named colours; `palette.zig` supplies their //! written entirely against named colors; `palette.zig` supplies their
//! values for the scheme in force, and the two are loaded as a single //! values for the scheme in force, and the two are loaded as a single
//! provider. //! 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 //! consults the style tree, so a CSS reload on its own would leave every
//! terminal grid painted in the palette it started with. //! 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 //! that property means a desktop that changes scheme at sunset repaints this
//! app too, with no extra machinery and no second source of truth. //! 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 //! without the trip through libadwaita: the scheme hasn't changed, only what it
//! is painted in. //! is painted in.
@@ -33,14 +33,14 @@ const Settings = @import("Settings.zig");
const palette = @import("palette.zig"); const palette = @import("palette.zig");
const theme = @import("theme.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 /// name, and the names are defined by the block `palette.zig` writes ahead of
/// it. /// it.
const style = @embedFile("style.css"); const style = @embedFile("style.css");
/// The two are handed to GTK as one string, so they live in one buffer. Static /// 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 /// 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. /// stylesheet that half applies.
var css_buf: [palette.css_size + style.len + 1]u8 = undefined; 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 /// 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; pub const Callback = *const fn (ctx: ?*anyopaque) void;
var provider: ?*gtk.CssProvider = null; var provider: ?*gtk.CssProvider = null;
@@ -141,7 +141,7 @@ fn onDarkChanged(
/// Repaint in the palette as it now stands, without touching the preference. /// 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 /// whatever it already was, and everything painted from it — the stylesheet, the
/// terminal grids — is rebuilt from the new value. /// terminal grids — is rebuilt from the new value.
pub fn refresh() void { pub fn refresh() void {
+1 -1
View File
@@ -10,7 +10,7 @@
//! Unicode lists nearly twice this many fully-qualified sequences and the //! Unicode lists nearly twice this many fully-qualified sequences and the
//! difference is almost entirely the same gesture in five tones, which makes a //! 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 //! 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 //! font has never heard of draws as a hex-digit box; the generator's header
//! explains how to raise it. //! explains how to raise it.
//! //!
+336
View File
@@ -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)"));
}
+20 -20
View File
@@ -1,6 +1,6 @@
//! sRGB in, sRGB out, with a perceptual space in the middle. //! 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 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 //! 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 //! — 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: //! 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. //! 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 //! 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 //! 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 //! 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 //! 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 //! 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; 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 { pub const Lch = struct {
/// Perceptual lightness. 0 is black, 1 is white, and 0.5 looks like a /// Perceptual lightness. 0 is black, 1 is white, and 0.5 looks like a
/// midtone rather than merely being one arithmetically. /// midtone rather than merely being one arithmetically.
l: f32, 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. /// depends on both the lightness and the hue, and is around 0.32 at best.
c: f32, c: f32,
@@ -59,17 +59,17 @@ pub fn fromRgb(rgb: Rgb) Lch {
return .{ return .{
.l = lab.l, .l = lab.l,
.c = c, .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 // 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 // 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 // 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)), .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 { pub fn toRgb(lch: Lch) Rgb {
const l = std.math.clamp(lch.l, 0, 1); const l = std.math.clamp(lch.l, 0, 1);
const rad = std.math.degreesToRadians(lch.h); 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 /// 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 /// 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 /// 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 { fn fit(l: f32, c: f32, rad: f32) f32 {
if (Lab.at(l, c, rad).inGamut()) return c; 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 /// 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. /// by the bisection above for no visible gain.
fn inGamut(self: Lab) bool { fn inGamut(self: Lab) bool {
const lin = self.toLinear(); const lin = self.toLinear();
@@ -188,8 +188,8 @@ fn encode(channel: f32) u8 {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
test "a colour survives the round trip" { test "a color survives the round trip" {
// Every hue family, plus the two ends and a grey, since those are the cases // 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. // where the hue is undefined and the transfer function is at its steepest.
const cases = [_][]const u8{ const cases = [_][]const u8{
"#000000", "#ffffff", "#808080", "#3a76f0", "#d2691e", "#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, 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); 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. // of two zeroes.
const grey = fromRgb(.{ .r = 128, .g = 128, .b = 128 }); const gray = fromRgb(.{ .r = 128, .g = 128, .b = 128 });
try std.testing.expectApproxEqAbs(@as(f32, 0), grey.c, 1e-3); try std.testing.expectApproxEqAbs(@as(f32, 0), gray.c, 1e-3);
try std.testing.expectEqual(@as(f32, 0), grey.h); 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 // A fully saturated yellow at the lightness of a midtone: nothing like it
// exists in sRGB, and asking for it has to produce *something*. // exists in sRGB, and asking for it has to produce *something*.
const asked: Lch = .{ .l = 0.5, .c = 0.3, .h = 100 }; 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.expectApproxEqAbs(asked.h, got.h, 1.5);
try std.testing.expect(got.c < asked.c); 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. // 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()); try std.testing.expect(!Lab.at(asked.l, got.c + 0.01, std.math.degreesToRadians(asked.h)).inGamut());
} }
+53 -53
View File
@@ -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. //! to in either scheme, and the shape an override takes when someone changes it.
//! //!
//! This used to be two stylesheets — `palette-dark.css` and //! This used to be two stylesheets — `palette-dark.css` and
//! `palette-light.css`, one of them prepended to `style.css` at compile time — //! `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 //! 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 //! 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 //! 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 //! So the CSS is generated from here instead: `writeCss` emits one
//! `@define-color` per name, `appearance.zig` prepends the result to //! `@define-color` per name, `appearance.zig` prepends the result to
//! `style.css`, and `theme.zig` resolves the terminal renderer's colours out of //! `style.css`, and `theme.zig` resolves the terminal renderer's colors out of
//! the same table. There is still exactly one place a colour is written down. //! 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 //! 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 //! "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. //! 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 //! 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 //! 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 //! most specific — this color, then the color built from your base, then the
//! colour Playpen ships. That ordering is what lets someone pick a base and then //! 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 //! 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 //! time they nudge the base, and it is why `Changes` is a struct rather than the
//! bare override array it used to be. //! 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 /// has a third option — see `Settings.Theme`. By the time a scheme reaches this
/// module, "system" has been resolved to one of these. /// module, "system" has been resolved to one of these.
pub const Scheme = enum { pub const Scheme = enum {
@@ -116,7 +116,7 @@ pub const Group = enum {
terminal, terminal,
ansi, 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 /// that don't are the terminal's: Cairo draws the grid itself and never
/// consults the style tree. /// consults the style tree.
pub fn isCss(self: Group) bool { pub fn isCss(self: Group) bool {
@@ -134,7 +134,7 @@ pub const Group = enum {
.accents => "Accent", .accents => "Accent",
.states => "Status", .states => "Status",
.terminal => "Terminal", .terminal => "Terminal",
.ansi => "Terminal ANSI colours", .ansi => "Terminal ANSI colors",
}; };
} }
@@ -143,17 +143,17 @@ pub const Group = enum {
return switch (self) { return switch (self) {
.surfaces => "Darkest first: the window backdrop shows through as the gutter between panes.", .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. " ++ .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.", "Left alone, these follow the surface, text and accent colors above.",
.ansi => "The 16 colours programs ask for by name. " ++ .ansi => "The 16 colors programs ask for by name. " ++
"The 240 above them are fixed by spec and not editable.", "The 240 above them are fixed by spec and not editable.",
.text, .rows, .accents, .states => null, .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 /// `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, /// 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. /// 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_bg,
term_selection_fg, term_selection_fg,
// ---- the 16 named ANSI colours, in palette order ---- // ---- the 16 named ANSI colors, in palette order ----
ansi_black, ansi_black,
ansi_red, ansi_red,
ansi_green, 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 /// at all. Derived from the tag rather than written out again, so the two
/// cannot drift. /// cannot drift.
pub fn cssName(self: Key) ?[:0]const u8 { 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 { pub fn ansiIndex(self: Key) ?u8 {
if (self.group() != .ansi) return null; if (self.group() != .ansi) return null;
return @intCast(@intFromEnum(self) - @intFromEnum(Key.ansi_black)); 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 /// 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 /// terminal's background *is* the pane surface, its foreground *is* the
/// body text, and a selection *is* the muted accent — until someone says /// body text, and a selection *is* the muted accent — until someone says
/// otherwise, at which point they come apart, which is the whole reason /// 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. /// story. Shown as the row's tooltip.
pub fn hint(self: Key) ?[:0]const u8 { pub fn hint(self: Key) ?[:0]const u8 {
return switch (self) { return switch (self) {
@@ -326,7 +326,7 @@ pub const Key = enum {
.surface_raised => "The strip above a pane holding its title", .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", .border_strong => "Edges that have to carry on their own, without a fill behind them",
.text_dim => "Secondary labels — hints, counts, addresses", .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_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", .accent_muted => "Washes and the border of the focused pane",
.ok => "A pane that finished and hasn't been looked at", .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. /// whatever it inherits from.
pub fn default(self: Key, scheme: Scheme) Rgb { pub fn default(self: Key, scheme: Scheme) Rgb {
if (self.ansiIndex()) |i| return switch (scheme) { if (self.ansiIndex()) |i| return switch (scheme) {
@@ -360,17 +360,17 @@ pub const Key = enum {
pub const count = std.enums.values(Key).len; 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 /// 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. /// makes "is this key overridden" a lookup rather than a search.
pub const Overrides = std.enums.EnumArray(Key, ?Rgb); pub const Overrides = std.enums.EnumArray(Key, ?Rgb);
pub const no_overrides: Overrides = .initFill(null); 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. /// `tint.zig` for what "built around it" means.
pub const Tint = struct { 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`. /// `accent` comes out as — see `tint.derive`.
base: Rgb, base: Rgb,
@@ -384,8 +384,8 @@ pub const Tint = struct {
} }
}; };
/// Everything someone has done to one scheme's palette: the base colour it is /// Everything someone has done to one scheme's palette: the base color it is
/// generated from, if any, and the individual colours they have set by hand. /// 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 /// 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, /// 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, overrides: Overrides = no_overrides,
/// Whether anything in this scheme has been changed at all. What the /// 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 { pub fn anySet(self: *const Changes) bool {
if (self.tint != null) return true; if (self.tint != null) return true;
for (std.enums.values(Key)) |key| { 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 /// without any settings to hand — the terminal renderer's compile-time initial
/// palette, and the tests — have something to point at. /// palette, and the tests — have something to point at.
pub const unchanged: Changes = .{}; pub const unchanged: Changes = .{};
/// The colour to paint with: the override if there is one, else what the key /// The color 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 /// inherits (overrides and all), else the color a base generates for it, else
/// the default. /// the default.
/// ///
/// The inheritance is resolved through the overrides rather than around them, /// 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 /// follows, without anyone having had to set both. It is resolved *before* the
/// generated palette for the same reason: a terminal background whose pane /// 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. /// the override was correcting.
pub fn resolve(key: Key, scheme: Scheme, changes: *const Changes) Rgb { pub fn resolve(key: Key, scheme: Scheme, changes: *const Changes) Rgb {
if (changes.overrides.get(key)) |color| return color; 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); 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. /// against.
/// ///
/// `appearance.zig` prepends this to the stylesheet and loads the pair as a /// `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`. /// Cairo instead, through `theme.zig`.
pub fn writeCss( pub fn writeCss(
scheme: Scheme, 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 // 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 // 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); const Table = std.enums.EnumArray(Key, ?Rgb);
/// The default for a key the table below doesn't mention: no colour of its own, /// The default for a key the table below doesn't mention: no color of its own,
/// meaning it inherits one or is an ANSI colour taken from libghostty-vt. Named /// 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 /// 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. /// table has no default" and make every key mandatory.
const unset: ?Rgb = null; 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 /// three surfaces — window, sidebar, pane — separate without any of them
/// reading as light. /// reading as light.
const dark: Table = .initDefault(unset, .{ 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 // 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 // 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"), .ok = h("#3ecf8e"),
.warn = h("#f0b849"), .warn = h("#f0b849"),
.err = h("#f2717b"), .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 // 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. // a near-white background a bright block swallows the character under it.
.term_cursor = h("#6191f3"), .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 /// 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 /// `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 /// 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. /// reads clearly on navy and disappears on white.
/// ///
/// The text stops short of black. Full-contrast black on white is harsher to /// 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"), .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: /// 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 /// 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 /// Two entries change meaning rather than brightness. `white` (7) and `bright
/// white` (15) are the foreground half of "white text", and on a white /// 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 /// altogether. Every light terminal theme makes this trade: a program that
/// asked for a white *background* gets a dark block instead, which is jarring /// 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 /// 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 /// 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. /// structs of three numbers.
inline fn h(comptime text: []const u8) Rgb { inline fn h(comptime text: []const u8) Rgb {
comptime { comptime {
// Sixty of these are parsed at compile time, and the integer parser they // Sixty of these are parsed at compile time, and the integer parser they
// go through is not cheap by the branch counter's reckoning. // go through is not cheap by the branch counter's reckoning.
@setEvalBranchQuota(100_000); @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. // nothing will report until something is painted with it.
comptime { comptime {
for (std.enums.values(Key)) |key| { 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()); 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("#070c15").?, resolve(.bg, .dark, &unchanged));
try std.testing.expectEqual(Rgb.parse("#d9e1ee").?, resolve(.bg, .light, &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").?); changes.overrides.set(.surface, Rgb.parse("#123456").?);
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.term_bg, .dark, &changes)); 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").?); 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("#abcdef").?, resolve(.term_bg, .dark, &changes));
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.surface, .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").? } }; 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.expect(!resolve(.bg, .dark, &changes).eql(Key.bg.default(.dark)));
try std.testing.expectEqual( try std.testing.expectEqual(
Rgb.parse("#d2691e").?, 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), 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. // round, which is what lets the two be used together.
changes.overrides.set(.accent, Rgb.parse("#00ff00").?); changes.overrides.set(.accent, Rgb.parse("#00ff00").?);
try std.testing.expectEqual(Rgb.parse("#00ff00").?, resolve(.accent, .dark, &changes)); 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()); 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 buf: [css_size]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf); var writer: std.Io.Writer = .fixed(&buf);
+1 -1
View File
@@ -218,7 +218,7 @@ fn save(self: *Store) Error!void {
/// Deliberately not filtered by diff context. A comment is content someone /// Deliberately not filtered by diff context. A comment is content someone
/// typed: it has to survive the base ref moving, the working tree being /// 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 /// 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. /// 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 { pub fn list(self: *Store, gpa: std.mem.Allocator) ![]Comment {
self.mutex.lockUncancelable(self.io); self.mutex.lockUncancelable(self.io);
+50 -33
View File
@@ -1,15 +1,15 @@
/* Zen-style vertical tabs: a sidebar column with the terminal inset to its /* Zen-style vertical tabs: a sidebar column with the terminal inset to its
right. 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 `@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 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, 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 `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 colour and the two state's tint is always derived from the state's own color and the two
can't drift apart. */ can't drift apart. */
.playpen-window { .playpen-window {
@@ -33,7 +33,7 @@
The rule underneath is stated explicitly rather than left to the one Adwaita 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 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. */ it now makes with the footer's. */
.playpen-sidebar headerbar { .playpen-sidebar headerbar {
background: none; background: none;
@@ -44,7 +44,7 @@
} }
/* The two header actions. Styled explicitly rather than left to Adwaita, whose /* 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 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 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. 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 /* 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 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) { .playpen-list > row:selected image:not(.playpen-status-dot) {
color: @pp_accent_strong; 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 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 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 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. */ glyph fills its box where a symbolic icon leaves air around itself. */
.playpen-tab-emoji { .playpen-tab-emoji {
min-width: 16px; min-width: 16px;
@@ -133,7 +133,7 @@ button.playpen-header-button:hover,
/* State on the row itself, not just on its dot. /* 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 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 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. 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 /* 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 is the part that carries the state. Done is absent because it no longer has a
wash to suppress. */ wash to suppress. */
.playpen-list > row:selected.playpen-attn-input, .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; 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 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 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 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, /* 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 colored entirely by which state class is on the icon. Symbolic icons take
their colour from `color`, so nothing here needs a second asset. 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 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 { .playpen-status-dot {
-gtk-icon-size: 11px; -gtk-icon-size: 11px;
opacity: 0; opacity: 0;
@@ -375,6 +375,23 @@ dnd.playpen-tab-drag {
border-radius: 6px; 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 /* A web pane's navigation bar, below the pane header. Kept visually quieter
than the header so the two rows don't compete. */ than the header so the two rows don't compete. */
.playpen-nav { .playpen-nav {
@@ -445,7 +462,7 @@ dnd.playpen-tab-drag {
asking without being read pane by pane. asking without being read pane by pane.
Only the three states that are news get a border. Working deliberately does 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 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. 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; 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 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 the title, which is what makes a pane readable at a glance in a four-way
split. */ split. */
@@ -601,9 +618,9 @@ dnd.playpen-tab-drag {
choice is visible without opening anything, and so the one you are on is choice is visible without opening anything, and so the one you are on is
readable at a glance rather than by reading a word. */ readable at a glance rather than by reading a word. */
/* `background-image: none` on every rule below is load-bearing. Adwaita paints /* `background-image: none` on every rule below is load-bearing. Adwaita paints
button states with a gradient *over* the background colour, so setting the button states with a gradient *over* the background color, so setting the
colour alone leaves a washed-out version of it most visibly on the checked color 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, where the accent came out as a pale gray-blue. `:backdrop` is
spelled out for the same reason: Adwaita dims unfocused windows, and the 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 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. */ 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 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 section would read as noise. The space between sections does that work
instead. */ instead. */
@@ -665,10 +682,10 @@ dnd.playpen-tab-drag {
color: @pp_text_faint; color: @pp_text_faint;
} }
/* Adwaita gives a colour button the full button treatment: a gradient over the /* Adwaita gives a color button the full button treatment: a gradient over the
colour, and a border in a grey belonging to neither palette. What is wanted color, and a border in a gray belonging to neither palette. What is wanted
is a rectangle of the colour with this app's border around it, so the swatch is a rectangle of the color with this app's border around it, so the swatch
reads as the colour rather than as a button holding one. */ reads as the color rather than as a button holding one. */
.playpen-color-group colorbutton > button { .playpen-color-group colorbutton > button {
padding: 0; padding: 0;
min-width: 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 /* 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 paints a scale in the *desktop's* accent color, which on a page whose whole
subject is the colour this window is painted from would be the one thing on subject is the color this window is painted from would be the one thing on
screen ignoring it. Left stock, dragging it towards a warm theme leaves a screen ignoring it. Left stock, dragging it toward a warm theme leaves a
blue bar sitting in the middle of the result. */ blue bar sitting in the middle of the result. */
.playpen-contrast trough { .playpen-contrast trough {
background-color: @pp_surface_raised_active; 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 /* 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. */ without this the accent comes out washed. */
.playpen-emoji-grid > flowboxchild:selected, .playpen-emoji-grid > flowboxchild:selected,
.playpen-emoji-grid > flowboxchild:selected:backdrop { .playpen-emoji-grid > flowboxchild:selected:backdrop {
+9 -9
View File
@@ -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. //! 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 //! (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 //! 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 //! 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 Rgb = palette.Rgb;
pub const Scheme = palette.Scheme; 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 { const Resolved = struct {
/// Terminal default background, used when the program hasn't set one. /// Terminal default background, used when the program hasn't set one.
bg: Rgb, bg: Rgb,
@@ -32,19 +32,19 @@ const Resolved = struct {
/// Terminal default foreground. /// Terminal default foreground.
fg: Rgb, fg: Rgb,
/// Cursor block colour. /// Cursor block color.
cursor: Rgb, cursor: Rgb,
/// Background and foreground of selected text. Both are fixed rather than /// Background and foreground of selected text. Both are fixed rather than
/// derived from the cell underneath, because a selection has to read as one /// derived from the cell underneath, because a selection has to read as one
/// continuous block across text the program has coloured every which way — /// continuous block across text the program has colored every which way —
/// a translucent wash over the existing colours leaves the highlight /// a translucent wash over the existing colors leaves the highlight
/// looking like a rendering artefact wherever the text is already bright. /// looking like a rendering artifact wherever the text is already bright.
selection_bg: Rgb, selection_bg: Rgb,
selection_fg: Rgb, selection_fg: Rgb,
/// The full 256-colour palette. The 240 above index 16 are fixed by the /// The full 256-color 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 /// 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 /// whatever the background is, so they are left exactly as libghostty-vt
/// built them; only the 16 named ones are ours to set. /// built them; only the 16 named ones are ours to set.
ansi: vt.color.Palette, ansi: vt.color.Palette,
+52 -52
View File
@@ -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 //! 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 //! 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 //! 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 //! grays behind the green to be the six grays that go with it. That is what this
//! module is: a base colour, and a rule for everything else. //! module is: a base color, and a rule for everything else.
//! //!
//! It sits underneath the per-colour overrides rather than replacing them (see //! It sits underneath the per-color overrides rather than replacing them (see
//! `palette.resolve`). Picking a base changes what every unedited colour //! `palette.resolve`). Picking a base changes what every unedited color
//! resolves to; a colour that has been set by hand still wins. So the two ways //! 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 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. //! 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 //! * 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 //! 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 //! weights of text on them. A green base makes them faintly green, a gray
//! base makes them grey, and their spacing never changes, because that //! base makes them gray, and their spacing never changes, because that
//! spacing is what tells a pane header from a pane. //! 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 //! 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. //! it did what you asked.
//! * The rest are *named* — red, green, "failed", "waiting on you". A red that //! * 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 //! 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, //! scaling it about a midpoint. The difference matters in the light scheme,
//! where the surfaces sit hard against white: scaling about a midpoint pushes //! 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 //! 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 //! only ever spread apart or draw together, which is the thing the setting
//! claims to do. //! claims to do.
//! //!
@@ -56,7 +56,7 @@ const Rgb = palette.Rgb;
const Scheme = palette.Scheme; const Scheme = palette.Scheme;
const Tint = palette.Tint; 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 /// 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 /// 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 ramp = lightness_ramp.get(scheme);
const shade: oklab.Lch = switch (stop.role) { 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 // 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 // so that a gray base yields a gray window — there is no floor here
// that would make a deliberately colourless theme come out faintly // that would make a deliberately colorless theme come out faintly
// blue. // blue.
.tinted => .{ .tinted => .{
.l = stop.l, .l = stop.l,
@@ -81,9 +81,9 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
.h = base.h, .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 // 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 // one thing that has to stand out invisible", and the two lighter and
// darker accents are then offsets from wherever it landed. // darker accents are then offsets from wherever it landed.
.accent => .{ .accent => .{
@@ -92,10 +92,10 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
.h = base.h, .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 // outright, and takes from the base only how loud to be — measured
// against the accent the shipped palette was tuned around. The floor // 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 // "finished" one; the ceiling keeps a neon base from producing sixteen
// more neons. // more neons.
.named => .{ .named => .{
@@ -116,23 +116,23 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Contrast // 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 /// 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 /// 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: /// 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 /// * 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 /// 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. /// 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 /// * 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 /// 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 /// proportion, rather than doing something dramatic at one end of the ladder
/// and nothing at the other. /// 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 /// 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. /// contrast stops making them starker and starts making them not red.
fn stretch(l: f32, contrast: f32, ramp: Ramp, spread: f32) f32 { 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. /// becomes bare white.
const contrast_reach = 0.5; const contrast_reach = 0.5;
/// Contrast drains a little colour out of the structural surfaces as it goes up /// 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 colours /// — a starker theme is a cleaner one — and puts a little back into the colors
/// whose job is to be seen. /// whose job is to be seen.
const chroma_calm = 0.20; const chroma_calm = 0.20;
const chroma_lift = 0.12; const chroma_lift = 0.12;
/// The chroma of the accent both shipped palettes are built around, which is /// 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 /// what a `named` color's intensity is measured against. A base this colorful
/// leaves the status and terminal colours exactly as the tables below name them. /// leaves the status and terminal colors exactly as the tables below name them.
const reference_chroma = 0.1944; const reference_chroma = 0.1944;
/// The two ends of a scheme's lightness ladder, and the band an accent has to /// The two ends of a scheme's lightness ladder, and the band an accent has to
/// land in to be legible against it. /// land in to be legible against it.
const Ramp = struct { 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. /// backdrop in the dark scheme, the body text in the light one.
lo: f32, lo: f32,
@@ -188,7 +188,7 @@ const Role = enum {
accent, accent,
named, 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`. /// `stretch`.
fn spread(self: Role) f32 { fn spread(self: Role) f32 {
return switch (self) { 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. /// role, which is why they are named for their role rather than for OKLCh.
const Stop = struct { const Stop = struct {
role: Role, role: Role,
/// `tinted` and `named`: the lightness outright. `accent`: an offset from /// `tinted` and `named`: the lightness outright. `accent`: an offset from
/// the base colour's own. /// the base color's own.
l: f32, l: f32,
/// `tinted` and `accent`: a multiple of the base colour's chroma. `named`: /// `tinted` and `accent`: a multiple of the base color's chroma. `named`:
/// a chroma outright, scaled by how colourful the base is. /// a chroma outright, scaled by how colorful the base is.
c: f32, 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, h: f32 = 0,
}; };
@@ -220,7 +220,7 @@ fn tinted(l: f32, c: f32) Stop {
return .{ .role = .tinted, .l = l, .c = c }; 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. /// stay the same distance apart wherever the base sits.
fn accent(dl: f32, c: f32) Stop { fn accent(dl: f32, c: f32) Stop {
return .{ .role = .accent, .l = dl, .c = c }; 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 /// 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 /// "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; const inherited: ?Stop = null;
fn stops(scheme: Scheme) *const Stops { 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. // the mid one, for the reason `palette.Key.hint` gives.
.term_cursor = accent(0.0742, 0.797), .term_cursor = accent(0.0742, 0.797),
// The four neutral ANSI colours are tinted rather than named: "black" and // The four neutral ANSI colors are tinted rather than named: "black" and
// "white" here are the ends of the terminal's own greyscale, and a terminal // "white" here are the ends of the terminal's own grayscale, and a terminal
// whose greys are a different grey from the window around it looks like a // whose grays are a different gray from the window around it looks like a
// pane that failed to load. // pane that failed to load.
.ansi_black = tinted(0.2380, 0.190), .ansi_black = tinted(0.2380, 0.190),
.ansi_red = named(0.6308, 0.150, 21.4), .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 // 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 // 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. // an inheritance the rest of the app is written against.
comptime { comptime {
for (std.enums.values(Key)) |key| { for (std.enums.values(Key)) |key| {
@@ -370,7 +370,7 @@ comptime {
const testing = std.testing; const testing = std.testing;
/// The largest difference in any channel, which is the useful measure here: a /// 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. /// in for, and a couple of steps in one channel is well inside that.
fn drift(a: Rgb, b: Rgb) u16 { fn drift(a: Rgb, b: Rgb) u16 {
return @max( return @max(
@@ -395,7 +395,7 @@ test "the accent regenerates the palette it was taken from" {
const generated = derive(key, scheme, tint); const generated = derive(key, scheme, tint);
const shipped = key.default(scheme); 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 // rather than a transcription — libghostty-vt's dark sixteen are
// washed out beside a full-strength accent, and the shipped light // washed out beside a full-strength accent, and the shipped light
// cyan is duller than the rest of its row. // 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" { test "the accent is the color that was picked" {
// Anything else makes the picker feel broken. The only licence taken is the // Anything else makes the picker feel broken. The only license taken is the
// lightness clamp, and these two sit well inside it. // lightness clamp, and these two sit well inside it.
for ([_][]const u8{ "#3a76f0", "#d2691e" }) |text| { for ([_][]const u8{ "#3a76f0", "#d2691e" }) |text| {
const base = Rgb.parse(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; const l = oklab.fromRgb(derive(key, scheme, tint)).l;
// Light steps *down* from its pane surface for the two // 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. // hold rather than the direction of all of them.
// //
// The floor is low because the tightest step in the shipped // The floor is low because the tightest step in the shipped
// dark palette — the sidebar against the pane beside it — is // dark palette — the sidebar against the pane beside it — is
// itself only 0.014, those two being separated as much by // 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 // contrast draws that to about 0.012, which is the number
// this has to sit under. What it is really watching for is a // this has to sit under. What it is really watching for is a
// step going to nothing. // 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(soft < level);
try testing.expect(level < stark); 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 // up against it, which is what a ramp that scaled about its middle would
// do to the light scheme. // do to the light scheme.
const tint: Tint = .{ .base = base, .contrast = 1 }; 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); 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").? }; const tint: Tint = .{ .base = Rgb.parse("#808080").? };
for (std.enums.values(Scheme)) |scheme| { 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| { for ([_]Key{ .bg, .surface, .text, .accent }) |key| {
try testing.expect(oklab.fromRgb(derive(key, scheme, tint)).c < 0.01); try testing.expect(oklab.fromRgb(derive(key, scheme, tint)).c < 0.01);
} }
+3 -3
View File
@@ -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 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. 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 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 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 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" 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" 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 # 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. # what you reach for when the tab is a deploy.
SYNONYMS = { 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 //! Unicode lists nearly twice this many fully-qualified sequences and the
//! difference is almost entirely the same gesture in five tones, which makes a //! 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 //! 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 //! font has never heard of draws as a hex-digit box; the generator's header
//! explains how to raise it. //! explains how to raise it.
//! //!
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- The topbar brand mark as a favicon: the Octicon `file-diff` glyph knocked <!-- The topbar brand mark as a favicon: the Octicon `file-diff` glyph knocked
out of a Primer-blue tile (bgColor.accent.emphasis #316dca, shaded a shade out of a Primer-blue tile (bgColor.accent.emphasis #316dca, shaded a shade
either side for depth). Same shape and colour as <Icon name="file-diff" /> either side for depth). Same shape and color as <Icon name="file-diff" />
in the header, so the tab matches the app. Vite copies public/ to dist/, in the header, so the tab matches the app. Vite copies public/ to dist/,
which web/embed.go ships inside the binary. --> which web/embed.go ships inside the binary. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+5 -5
View File
@@ -213,7 +213,7 @@ export default function App() {
// The parsed diff lives here rather than in DiffView because the comments rail // 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 // 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]); const parsedFiles = useMemo(() => (payload ? parseDiff(payload.patch) : []), [payload]);
// What each file's diff currently says, digested. Viewed marks are stored // What each file's diff currently says, digested. Viewed marks are stored
@@ -395,20 +395,20 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!path || !revision || stale) return; if (!path || !revision || stale) return;
let cancelled = false; let canceled = false;
let busy = false; let busy = false;
const check = async () => { const check = async () => {
// A hidden pane is a pane nobody is reading. It gets checked the moment it // A hidden pane is a pane nobody is reading. It gets checked the moment it
// comes back instead, which is when the answer matters. // comes back instead, which is when the answer matters.
if (cancelled || busy || document.hidden) return; if (canceled || busy || document.hidden) return;
busy = true; busy = true;
const seq = reqRef.current; const seq = reqRef.current;
try { try {
const { revision: now } = await api.revision(ctx, { ignoreWhitespace: ignoreWs }); const { revision: now } = await api.revision(ctx, { ignoreWhitespace: ignoreWs });
// A load that started while this was in flight has already answered the // A load that started while this was in flight has already answered the
// question, with a revision this closure doesn't know about. // 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); if (now && now !== revision) setStale(now);
} catch { } catch {
// A failed poll says nothing about the diff — the next one will. // A failed poll says nothing about the diff — the next one will.
@@ -424,7 +424,7 @@ export default function App() {
document.addEventListener('visibilitychange', onVisible); document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible); window.addEventListener('focus', onVisible);
return () => { return () => {
cancelled = true; canceled = true;
window.clearInterval(timer); window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible); document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible); window.removeEventListener('focus', onVisible);
+1 -1
View File
@@ -228,7 +228,7 @@ function SingleThread({
} }
// BodyEditor edits a comment's text in place of its rendered body. It starts // 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. // leaves the stored comment untouched.
function BodyEditor({ function BodyEditor({
initial, initial,
+5 -5
View File
@@ -206,12 +206,12 @@ function FileView({
setOldSource(null); setOldSource(null);
return; return;
} }
let cancelled = false; let canceled = false;
api.fileContent(base, file.oldPath).then((s) => { api.fileContent(base, file.oldPath).then((s) => {
if (!cancelled) setOldSource(s); if (!canceled) setOldSource(s);
}); });
return () => { return () => {
cancelled = true; canceled = true;
}; };
}, [base, file.oldPath, file.type]); }, [base, file.oldPath, file.type]);
@@ -227,14 +227,14 @@ function FileView({
// Highlighting is done over the *whole* file, never over the visible hunks // 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 // 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 // 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 // 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 // source (react-diff-view derives the head side by applying `hunks`) makes the
// result identical no matter what is expanded. // result identical no matter what is expanded.
// //
// A wholly added or deleted file needs no base source: its hunks already carry // 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 // 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 tokens: HunkTokens | undefined = useMemo(() => {
const lang = languageForFile(path); const lang = languageForFile(path);
if (!lang) return undefined; if (!lang) return undefined;
+2 -2
View File
@@ -5,8 +5,8 @@
// pulled in as a dependency because we need a dozen of ~600, and a local table // 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. // 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 // Every glyph is authored on a 16×16 grid with `fill: currentColor`, so color
// comes from the surrounding text colour and size from the `size` prop. // comes from the surrounding text color and size from the `size` prop.
const PATHS = { const PATHS = {
'chevron-down': 'chevron-down':
+9 -9
View File
@@ -1,7 +1,7 @@
/* ============================================================================ /* ============================================================================
review local code review UI 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 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 block is `light`, both at the values GitHub currently ships. The diff
geometry 12px/24px mono rows, 45px line-number columns, a 22px marker 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-soft: rgba(174, 124, 20, 0.15);
--attention-muted: rgba(174, 124, 20, 0.4); --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-bg: #263834; /* addition, code cell */
--add-gutter: #31503d; /* addition, line number cell */ --add-gutter: #31503d; /* addition, line number cell */
--add-word: #31583c; /* addition, word-level mark */ --add-word: #31583c; /* addition, word-level mark */
@@ -1153,7 +1153,7 @@ body.is-resizing {
border-left-color: var(--claude); border-left-color: var(--claude);
opacity: 0.6; 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. */ edge, since "can't find the code any more" is what you need to notice first. */
.comment-card.is-outdated { .comment-card.is-outdated {
border-left-color: var(--attention); 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 /* 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 { .oversize-notice {
max-width: 560px; max-width: 560px;
margin: 64px auto; margin: 64px auto;
@@ -1370,7 +1370,7 @@ body.is-resizing {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
} }
/* Primer Label: 999px pill, coloured border, 12px text. */ /* Primer Label: 999px pill, colored border, 12px text. */
.file-status { .file-status {
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 18px;
@@ -1468,7 +1468,7 @@ body.is-resizing {
color: var(--muted); 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. */ what the eye lands on. */
.file.is-viewed { .file.is-viewed {
border-color: var(--border-muted); border-color: var(--border-muted);
@@ -1529,7 +1529,7 @@ body.is-resizing {
} }
/* ---- Outdated comments -------------------------------------------------- */ /* ---- 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. */ comment is intact, only its anchor is gone. Nothing here reads as an error. */
.outdated-note { .outdated-note {
padding: 12px 16px; padding: 12px 16px;
@@ -1719,7 +1719,7 @@ body.is-resizing {
} }
/* A range being dragged, or one a pending comment is attached to. GitHub tints /* 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 { .diff-gutter-selected {
background: var(--attention-muted); background: var(--attention-muted);
color: var(--text); color: var(--text);
@@ -1812,7 +1812,7 @@ body.is-resizing {
} }
/* ---- Primer prettylights syntax theme ----------------------------------- */ /* ---- 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. */ highlighted share them: the diff, and a fenced block in a comment. */
:is(.diff, .md) .token.comment, :is(.diff, .md) .token.comment,
:is(.diff, .md) .token.prolog, :is(.diff, .md) .token.prolog,