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
+84 -35
View File
@@ -116,7 +116,7 @@ just the VT core.
```
main.zig AdwApplication startup
appearance.zig the colour scheme: preference -> libadwaita, CSS, palette
appearance.zig the color scheme: preference -> libadwaita, CSS, palette
Window.zig sidebar + GtkStack of views, tab management, reordering, shortcuts
View.zig one tab's content: its panes, their layout, and drag handling
Layout.zig the split tree: nodes, rearranging, GtkPaned materialization
@@ -141,11 +141,12 @@ Session.zig libghostty-vt Terminal + parser, fed by the PTY
Pty.zig openpt/fork/exec, controlling terminal setup
script.zig runs a command for `$(...)` in a layout's directory
key.zig GDK keyval -> libghostty-vt key mapping
palette.zig every colour by name, its default per scheme, and the CSS for it
notify.zig desktop notifications on a finish, and the mutes that stop them
palette.zig every color by name, its default per scheme, and the CSS for it
theme.zig the palette resolved for the terminal renderer, which reads numbers
Settings.zig preferences: theme, palette overrides, startup tabs, JSON on disk
SettingsDialog.zig the settings page, including the startup list editor
PaletteEditor.zig the colour editor inside it: a swatch per palette entry
PaletteEditor.zig the color editor inside it: a swatch per palette entry
TabSettingsDialog.zig one tab's own settings, and the emoji picker
emoji.zig generated: every emoji the picker offers, and the search over them
```
@@ -182,7 +183,7 @@ Running terminals keep their scrollback and processes straight through a move.
**Drags rearrange live.** Each time the drop target changes, the move is
applied for real, so the layout under the cursor is always the layout you will
get — there is no separate drop indicator because the view itself is the
preview. Cancelling a drag puts the pane back: only the dragged pane ever
preview. Canceling a drag puts the pane back: only the dragged pane ever
moves, so the rest of the tree is unchanged and re-inserting it beside its
original sibling restores the original shape.
@@ -225,7 +226,7 @@ save it, and replace the literal paths with `{{parameters}}`.
name, parameters and per-pane scripts can be changed without opening it.
Renaming moves the layout rather than copying it, and renaming onto a name
another layout already has is refused instead of quietly replacing it. Nothing
is written until you confirm, so cancelling leaves the layout untouched. To
is written until you confirm, so canceling leaves the layout untouched. To
change the *shape* of a layout, open it, rearrange the tab, and save over it
under the same name — the same tools you used to build it in the first place.
@@ -269,7 +270,7 @@ means `string`:
{ "name": "path", "description": "Project directory", "type": "directory" }
```
A type this build doesn't recognise is asked for in a text box rather than
A type this build doesn't recognize is asked for in a text box rather than
refusing the file — unlike a pane `kind`, which is a pane it cannot build. The
startup list asks for its values the same way, so a directory parameter gets the
same field there.
@@ -451,7 +452,7 @@ to notice you are about to close the wrong window.
**Keep Working** is the default response, and Escape lands on it too: the guard
should not itself be a keystroke to fumble. Pressing the compositor's close
binding again while the question is up dismisses it, which counts as cancelling
binding again while the question is up dismisses it, which counts as canceling
— leaning on the key never costs the window.
Two closes don't ask, because both are already an answer: accepting the dialog,
@@ -511,6 +512,11 @@ to closing the moment the window manager says so:
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
it is working, waiting on you, or finished and still unanswered. See
[Agent status](#agent-status)
- **A desktop notification when a pane finishes**, for the tabs you are not
looking at — on by default, switchable from **Settings → Notifications**, and
mutable one tab at a time from its right-click menu, for fifteen minutes, an
hour, eight hours, or until you say otherwise. See
[Notifications](#notifications)
- **Renaming a tab**: `Ctrl+Shift+R`, double-click a tab row, or **Rename** in
its right-click menu. A typed name pins the label; clearing it hands the
label back to the panes
@@ -531,11 +537,11 @@ to closing the moment the window manager says so:
- **Light and dark schemes**, following the desktop by default and pinnable
from the settings page, applying to open tabs immediately — terminal palette
included. See [Theme](#theme)
- **Every colour in the palette editable**, per scheme, from **Settings →
Appearance → Customise**: the surfaces, the text on them, the accents, the
status colours, and the terminal's own background, cursor, selection and 16
ANSI colours. Each change repaints as you make it. See
[Customising the palette](#customising-the-palette)
- **Every color in the palette editable**, per scheme, from **Settings →
Appearance → Customize**: the surfaces, the text on them, the accents, the
status colors, and the terminal's own background, cursor, selection and 16
ANSI colors. Each change repaints as you make it. See
[Customizing the palette](#customizing-the-palette)
### Shortcuts
@@ -725,7 +731,7 @@ table, and add to its `SYNONYMS` when a glyph should answer to a word the data
files don't know. Two things it leaves out. Skin-tone variants, because just over
half of Unicode's 3944 sequences are the same gesture in five tones and a grid of
them is longer to look through rather than more complete — every base glyph is
there. And anything newer than emoji 16.0, because colour fonts trail Unicode by
there. And anything newer than emoji 16.0, because color fonts trail Unicode by
a year or two and a glyph the font has never heard of draws as a hex-digit box,
which in a picker reads as a bug; `--max-version` raises the cutoff once fonts
have caught up.
@@ -923,11 +929,11 @@ So a tab can carry a dot:
| red | stopped on an error |
| none | idle |
Each colour is carried at three sizes, so it survives being glanced at rather
Each color is carried at three sizes, so it survives being glanced at rather
than read: an 11px dot on the row, a bar down the row's leading edge, and a wash
behind the whole row. Working gets the bar without the wash — it is the resting
state of a busy afternoon, and tinting half the sidebar all day would only teach
you to stop looking. Inside a tab, a pane that is asking colours its own frame
you to stop looking. Inside a tab, a pane that is asking colors its own frame
and header the same way, which is what picks it out of a four-way split.
The green one is the point of the feature. A session that has gone back to idle
@@ -959,6 +965,48 @@ so once the row has brought you to the tab, they say which pane inside it was
the one asking; the row stays lit until every finished pane in it has been
answered.
### Notifications
The dots answer "which of these needs me?" for a window that is in front of you.
When it isn't — behind an editor, on another workspace, or on the other monitor —
a pane finishing also posts a **desktop notification**, titled with the tab's
name and carrying that pane's title as its body. That title is the useful half:
the hook sets it from your prompt, so what the popup says is the task that just
finished rather than "playpen".
It is on by default and **Settings → Notifications** switches it off, which
leaves the sidebar dots as the only word you get:
```json
{
"notifications": false
}
```
Nothing is posted for a tab you were already looking at — the window has the
focus and that tab is the one on screen — because the dot has already said it
and a popup about the pane in front of you is the fastest way to make someone
switch the whole feature off. Coming back to a tab also takes down whatever
popup it left in the tray, the same visit that clears its row.
**One tab at a time can be muted** from its right-click menu, for 15 minutes, an
hour, 8 hours, or until you unmute it. That is the answer for the one session
that finishes every ninety seconds while the other three are worth hearing
about; reopening the menu says how long the mute has left and offers the way out
of it. A mute is session state and is not written to the settings file — a tab
does not outlive the app, and neither should a decision about the next hour.
Neither mute touches the dots. Muting is about being interrupted; the row is
still how you find out what happened while you were away.
The notification is sent as a `GNotification`, so GIO picks whichever backend
the session has and the popup gets Playpen's name and icon from the installed
desktop entry — which means it looks right once `mise run install` has put that
entry in place. Nothing is attached to a click: the app is deliberately
non-unique, so no process owns `dev.greyson.playpen` on the session bus and a
daemon calling an action back would find nobody home. Clicking closes the popup;
getting back to the window is the compositor's job, and it already has a binding
for that.
### How it gets there
Programs report state with **OSC 9;4** — the ConEmu progress protocol, the same
@@ -1049,8 +1097,8 @@ light and a dark scheme. `Ctrl+,` or the gear at the foot of the sidebar opens
**Settings**, whose Appearance section holds the choice: light, dark, or
**system**, which is the default and follows the desktop.
Every colour it resolves to can be changed — see
[Customising the palette](#customising-the-palette).
Every color it resolves to can be changed — see
[Customizing the palette](#customizing-the-palette).
The preference lives in `~/.config/playpen/settings.json` (or
`$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the
@@ -1061,6 +1109,7 @@ The preference lives in `~/.config/playpen/settings.json` (or
"version": 1,
"theme": "system",
"confirm_quit": true,
"notifications": true,
"colors": {},
"startup": []
}
@@ -1069,20 +1118,20 @@ The preference lives in `~/.config/playpen/settings.json` (or
Switching applies immediately to every open tab — nothing needs restarting, and
a shell that has been running all day repaints along with everything else.
Three separate colour systems have to agree for that to be true, which is what
Three separate color systems have to agree for that to be true, which is what
`appearance.zig` exists to arrange:
- **libadwaita's style manager** colours the stock widgets — popovers, entries,
- **libadwaita's style manager** colors the stock widgets — popovers, entries,
dialog chrome. It is told to force a scheme, or left on `default` to follow
the desktop.
- **`style.css`** colours everything the app draws itself. It is written
entirely against named colours; `palette.zig` writes the `@define-color`
- **`style.css`** colors everything the app draws itself. It is written
entirely against named colors; `palette.zig` writes the `@define-color`
block that defines them for the scheme in force, and the two are loaded as a
single `GtkCssProvider`. No rule in `style.css` may hardcode a colour — a
single `GtkCssProvider`. No rule in `style.css` may hardcode a color — a
literal hex is a rule that looks right in whichever scheme you happened to be
testing in, and one the colour editor cannot reach.
testing in, and one the color editor cannot reach.
- **`theme.zig`** holds what Cairo draws the terminal grid from: the default
background, foreground and cursor, plus the 16 ANSI colours. The style tree
background, foreground and cursor, plus the 16 ANSI colors. The style tree
is never consulted there, so a CSS reload alone would leave every terminal
painted in the scheme it started in. It resolves the same table `style.css`
is fed from, and caches the result — the renderer asks for the background
@@ -1092,7 +1141,7 @@ Three separate colour systems have to agree for that to be true, which is what
The ANSI palette is the part that is easy to skip and shouldn't be. The
standard xterm yellow is `#cdcd00`, which on a white background is close to
invisible — and prompts and build tools use it constantly, so a light scheme
without a light palette is a light scheme you can't read. The 240 colours above
without a light palette is a light scheme you can't read. The 240 colors above
index 16 are fixed by spec and left alone; only the 16 named ones change.
They are swapped through libghostty-vt's `DynamicPalette.changeDefault`, which
@@ -1106,20 +1155,20 @@ its `dark` property rather than the stored preference, and the preference only
decides what the style manager is told. A desktop that switches at sunset takes
this app with it, with no extra machinery and no second source of truth.
### Customising the palette
### Customizing the palette
**Settings → Appearance → Customise** opens the palette itself: a swatch for
every colour named above, in sections — surfaces, text, tab rows, accent,
**Settings → Appearance → Customize** opens the palette itself: a swatch for
every color named above, in sections — surfaces, text, tab rows, accent,
status, then the terminal's own background, foreground, cursor and selection,
and the 16 ANSI colours as a strip. Beside each row is the hex it currently
and the 16 ANSI colors as a strip. Beside each row is the hex it currently
resolves to, so a palette can be read off as text rather than only picked at,
and a button that puts that one colour back. Each section heading has the same
and a button that puts that one color back. Each section heading has the same
button for the section, and the foot of the list has one for the whole scheme.
Three things about how it behaves, all following from the same decision — that
the editor edits **the palette you can see**:
- **A change applies as you make it.** Every colour in the list paints something
- **A change applies as you make it.** Every color in the list paints something
behind the dialog, so the swatch is its own preview; there is no OK button
because there is nothing pending to confirm.
- **It edits the scheme in force.** Which is why the way to edit the other one is
@@ -1128,9 +1177,9 @@ the editor edits **the palette you can see**:
you the dark palette you built.
- **The terminal follows the window.** Left alone, the terminal's background *is*
the pane surface, its foreground *is* the body text, and its selection *is* the
muted accent — so recolouring the app recolours the terminal drawn inside it,
muted accent — so recoloring the app recolors the terminal drawn inside it,
and the frame around a terminal keeps matching its contents. Setting one of
those explicitly breaks the link for that colour and only that colour.
those explicitly breaks the link for that color and only that color.
Only what has been changed is stored, per scheme, under `colors` in
`settings.json`:
@@ -1150,12 +1199,12 @@ short and hand-editable, it is what lets a row know whether it has been changed,
and it means the shipped defaults can be retuned in a later version and still
reach everyone who never touched them — rather than only the people who had
never opened the editor. A name this build doesn't know, or a value that isn't
`#rrggbb`, costs that one colour and is logged; the rest of the file is read
`#rrggbb`, costs that one color and is logged; the rest of the file is read
normally.
`palette.zig` is where the names, the defaults and the grouping live, which is
why there is no longer a `palette-dark.css`. An editor needs to know what the
colours are called, what they started as, and which ones belong together, and
colors are called, what they started as, and which ones belong together, and
none of that can be read back out of a stylesheet without parsing it — so the
table answers all of it and the CSS is generated from the table.