Add notifications.
This commit is contained in:
@@ -119,10 +119,10 @@ didn't touch, unless the change made it newly wrong. In rough priority order:
|
||||
leaves state half-updated, panics on input the caller controls.
|
||||
3. **Resources and lifetimes** — leaks, missing close/cancel, work that outlives
|
||||
what it belongs to.
|
||||
4. **Concurrency** — data races, state mutated without the lock its neighbours
|
||||
4. **Concurrency** — data races, state mutated without the lock its neighbors
|
||||
take, deadlock ordering.
|
||||
5. **Interface and contract** — a caller that can now be silently wrong; a
|
||||
behaviour change not reflected in the doc comment right above it.
|
||||
behavior change not reflected in the doc comment right above it.
|
||||
6. **Tests** — a new branch with real failure modes and no test; a test that
|
||||
would pass with the bug still in.
|
||||
7. **Fit** — code that ignores an existing helper, layering, or naming pattern
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ pub fn build(b: *std.Build) void {
|
||||
paths_tests.root_module.addImport("glib", gobject.module("glib2"));
|
||||
test_step.dependOn(&b.addRunArtifact(paths_tests).step);
|
||||
|
||||
// The palette is the third root: a table of colours, the rules for
|
||||
// The palette is the third root: a table of colors, the rules for
|
||||
// resolving one, and the CSS it is written out as. It reaches libghostty-vt
|
||||
// for the terminal's own default palette and nothing else, so it tests
|
||||
// without a display in the same way the two above do.
|
||||
@@ -225,6 +225,23 @@ pub fn build(b: *std.Build) void {
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(store_tests).step);
|
||||
|
||||
// The mute rules are their own root. Whether a tab is silent at this instant,
|
||||
// and what its row menu says about that, is arithmetic on a deadline — and
|
||||
// it is the half of notifications that can be wrong without anyone noticing,
|
||||
// since the symptom is a popup that didn't arrive. It reaches gio to hand a
|
||||
// notification to the session, which is the part these tests leave alone.
|
||||
const notify_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/notify.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
}),
|
||||
});
|
||||
notify_tests.root_module.addImport("gio", gobject.module("gio2"));
|
||||
notify_tests.root_module.addImport("glib", gobject.module("glib2"));
|
||||
test_step.dependOn(&b.addRunArtifact(notify_tests).step);
|
||||
|
||||
const emoji_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/emoji.zig"),
|
||||
|
||||
Vendored
+2
-2
@@ -4,12 +4,12 @@
|
||||
edge. Shapes are kept large and high-contrast so the sidebar is still
|
||||
readable when the icon is scaled down to 32px in a launcher.
|
||||
|
||||
Colours track the app's dark palette — the navy surfaces of
|
||||
Colors track the app's dark palette — the navy surfaces of
|
||||
`palette-dark.css` and Signal's ultramarine as the accent. An icon is the
|
||||
one part of the app that is looked at next to a row of other icons rather
|
||||
than on its own, so it stays on the dark scheme in both: a launcher shelf
|
||||
where one icon repaints itself with the desktop theme is a worse result than
|
||||
one that is simply recognisable.
|
||||
one that is simply recognizable.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
+1
-1
@@ -315,7 +315,7 @@ fn assemble(node: *Node) void {
|
||||
}
|
||||
|
||||
/// The side `node` sits on within its parent split, and the parent's ratio.
|
||||
/// Used to put a pane back where it started when a drag is cancelled.
|
||||
/// Used to put a pane back where it started when a drag is canceled.
|
||||
pub fn positionOf(node: *Node) ?struct { sibling: *Node, side: Side, ratio: f64 } {
|
||||
const parent = node.parent orelse return null;
|
||||
const s = parent.kind.split;
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node {
|
||||
|
||||
/// A parameter's type, defaulting to a plain string.
|
||||
///
|
||||
/// An unrecognised type is a string rather than a parse error, which is the
|
||||
/// An unrecognized type is a string rather than a parse error, which is the
|
||||
/// opposite of how a leaf's `kind` is treated — and deliberately. A `kind` this
|
||||
/// build has never heard of is a pane it cannot build; a *type* it has never
|
||||
/// heard of is only a prompt it cannot improve on, and asking for it in a text
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn present(
|
||||
for (layout.parameters, 0..) |param, i| {
|
||||
const label = gtk.Label.new(null);
|
||||
// The description is what the author wrote for a human; the bare name
|
||||
// is the fallback so a parameter is never unlabelled.
|
||||
// is the fallback so a parameter is never unlabeled.
|
||||
var label_buf: [128]u8 = undefined;
|
||||
const text = if (param.description.len > 0) param.description else param.name;
|
||||
label.setText(std.fmt.bufPrintZ(&label_buf, "{s}", .{text}) catch "parameter");
|
||||
|
||||
+54
-54
@@ -1,30 +1,30 @@
|
||||
//! The colour editor: one swatch per name in the palette, for the scheme that
|
||||
//! The color editor: one swatch per name in the palette, for the scheme that
|
||||
//! is on screen.
|
||||
//!
|
||||
//! It edits **the palette you can see**, and that is the whole design. Every
|
||||
//! colour here paints something behind this window, so a change is its own
|
||||
//! preview — pick a sidebar colour and the sidebar is that colour before the
|
||||
//! color here paints something behind this window, so a change is its own
|
||||
//! preview — pick a sidebar color and the sidebar is that color before the
|
||||
//! dialog has closed. Editing the *other* scheme's palette from here would mean
|
||||
//! a page full of swatches whose effect you have to take on faith, so the
|
||||
//! answer to "I want to change the light one" is the theme picker above: switch
|
||||
//! to light, and this section switches with it.
|
||||
//!
|
||||
//! Overrides are stored per colour rather than as a whole palette, which is what
|
||||
//! Overrides are stored per color rather than as a whole palette, which is what
|
||||
//! lets a row say whether it has been changed and put itself back, and what lets
|
||||
//! the defaults be retuned in a later version for everyone who never touched
|
||||
//! them. The terminal's colours go one step further: left alone they *follow*
|
||||
//! the surface and text colours above them (see `palette.Key.inherits`), so
|
||||
//! recolouring the app recolours the terminal drawn on it without asking anyone
|
||||
//! to set the same colour twice — which is also why every row is re-read after
|
||||
//! them. The terminal's colors go one step further: left alone they *follow*
|
||||
//! the surface and text colors above them (see `palette.Key.inherits`), so
|
||||
//! recoloring the app recolors the terminal drawn on it without asking anyone
|
||||
//! to set the same color twice — which is also why every row is re-read after
|
||||
//! any change, not just the row that changed.
|
||||
//!
|
||||
//! The swatches start collapsed, and the two controls above them do not. That
|
||||
//! split is the point of the base colour: thirty-nine swatches is a fair way to
|
||||
//! split is the point of the base color: thirty-nine swatches is a fair way to
|
||||
//! *correct* a theme and a miserable way to *choose* one, so the path that is
|
||||
//! always on offer is "pick a colour, get a palette" (`tint.zig`), and the
|
||||
//! always on offer is "pick a color, get a palette" (`tint.zig`), and the
|
||||
//! wall of swatches is behind a disclosure for the times that isn't enough. The
|
||||
//! two compose — a swatch that has been set by hand outranks the generated
|
||||
//! colour underneath it — which is why picking a new base does not clear the
|
||||
//! color underneath it — which is why picking a new base does not clear the
|
||||
//! corrections someone made to the last one.
|
||||
|
||||
const std = @import("std");
|
||||
@@ -49,7 +49,7 @@ pub const Options = struct {
|
||||
ctx: ?*anyopaque = null,
|
||||
};
|
||||
|
||||
/// One colour: its swatch, and — for the ones listed as rows rather than packed
|
||||
/// One color: its swatch, and — for the ones listed as rows rather than packed
|
||||
/// into the ANSI grid — the hex it currently resolves to and the button that
|
||||
/// puts it back.
|
||||
const Row = struct {
|
||||
@@ -60,7 +60,7 @@ const Row = struct {
|
||||
revert: ?*gtk.Button = null,
|
||||
};
|
||||
|
||||
/// One section heading, kept only so its reset button can be greyed out when
|
||||
/// One section heading, kept only so its reset button can be grayed out when
|
||||
/// the section has nothing to reset.
|
||||
const GroupHeader = struct {
|
||||
editor: *PaletteEditor,
|
||||
@@ -81,14 +81,14 @@ body: *gtk.Box,
|
||||
/// Says which scheme is being edited, and is rewritten when that changes.
|
||||
scheme_hint: *gtk.Label,
|
||||
|
||||
/// The colour the rest of the palette is generated from, the hex it currently
|
||||
/// The color the rest of the palette is generated from, the hex it currently
|
||||
/// stands at, and the button that drops it. Assigned in `build`, since the
|
||||
/// swatch has to be handed its dialog at construction.
|
||||
base: *gtk.ColorDialogButton,
|
||||
base_hex: *gtk.Label,
|
||||
base_clear: *gtk.Button,
|
||||
|
||||
/// How far apart to spread the colours built from the base. Insensitive until
|
||||
/// How far apart to spread the colors built from the base. Insensitive until
|
||||
/// there is a base, since on its own it has nothing to spread.
|
||||
contrast: *gtk.Scale,
|
||||
|
||||
@@ -96,7 +96,7 @@ contrast: *gtk.Scale,
|
||||
reset: *gtk.Button,
|
||||
|
||||
/// Indexed by `@intFromEnum(key)`, so a row is found without searching. Inline
|
||||
/// in the struct rather than allocated: there is exactly one row per colour, the
|
||||
/// in the struct rather than allocated: there is exactly one row per color, the
|
||||
/// count is known at compile time, and the addresses have to be stable because
|
||||
/// every swatch's callback holds one.
|
||||
rows: [palette.count]Row,
|
||||
@@ -124,7 +124,7 @@ pub fn create(alloc: std.mem.Allocator, opts: Options) !*PaletteEditor {
|
||||
// The step is what the arrow keys move by; the range is the one
|
||||
// `palette.Tint.contrast` is defined over.
|
||||
.contrast = gtk.Scale.newWithRange(.horizontal, -1, 1, 0.05),
|
||||
.reset = gtk.Button.newWithLabel("Reset every colour"),
|
||||
.reset = gtk.Button.newWithLabel("Reset every color"),
|
||||
.rows = undefined,
|
||||
.groups = undefined,
|
||||
.on_report = opts.on_report,
|
||||
@@ -160,8 +160,8 @@ pub fn refresh(self: *PaletteEditor) void {
|
||||
fn build(self: *PaletteEditor) void {
|
||||
self.root.append(self.buildHeader());
|
||||
|
||||
// Above the base colour rather than inside the disclosure with the
|
||||
// swatches, because it is true of the base colour too: both palettes can be
|
||||
// Above the base color rather than inside the disclosure with the
|
||||
// swatches, because it is true of the base color too: both palettes can be
|
||||
// given one, and this is the only thing on the page that says which of them
|
||||
// the control below is about to change.
|
||||
self.scheme_hint.setXalign(0);
|
||||
@@ -176,8 +176,8 @@ fn build(self: *PaletteEditor) void {
|
||||
self.reset.as(gtk.Widget).addCssClass("flat");
|
||||
self.reset.as(gtk.Widget).setHalign(.start);
|
||||
self.reset.as(gtk.Widget).setTooltipText(
|
||||
"Put every colour in this scheme back to the one Playpen ships with, " ++
|
||||
"and drop the base colour above",
|
||||
"Put every color in this scheme back to the one Playpen ships with, " ++
|
||||
"and drop the base color above",
|
||||
);
|
||||
_ = gtk.Button.signals.clicked.connect(self.reset, *PaletteEditor, &onReset, self, .{});
|
||||
self.body.append(self.reset.as(gtk.Widget));
|
||||
@@ -201,7 +201,7 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget {
|
||||
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
labels.append(name.as(gtk.Widget));
|
||||
|
||||
const sub = gtk.Label.new("Every colour the window and the terminal are painted from.");
|
||||
const sub = gtk.Label.new("Every color the window and the terminal are painted from.");
|
||||
sub.setXalign(0);
|
||||
sub.setWrap(1);
|
||||
sub.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
|
||||
@@ -217,7 +217,7 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget {
|
||||
wrap.as(gtk.Widget).addCssClass("playpen-settings-choice");
|
||||
wrap.as(gtk.Widget).setValign(.center);
|
||||
|
||||
const toggle = gtk.ToggleButton.newWithLabel("Customise");
|
||||
const toggle = gtk.ToggleButton.newWithLabel("Customize");
|
||||
_ = gtk.ToggleButton.signals.toggled.connect(
|
||||
toggle,
|
||||
*PaletteEditor,
|
||||
@@ -232,7 +232,7 @@ fn buildHeader(self: *PaletteEditor) *gtk.Widget {
|
||||
return row.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// The two controls that are always on offer: the colour the palette is built
|
||||
/// The two controls that are always on offer: the color the palette is built
|
||||
/// out of, and how far apart to spread what gets built.
|
||||
///
|
||||
/// Outside the disclosure below on purpose. This is the answer for someone who
|
||||
@@ -244,16 +244,16 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
|
||||
const group = gtk.Box.new(.vertical, 6);
|
||||
|
||||
// The same class the swatch sections below carry, which is where the
|
||||
// stylesheet hangs the "a colour button is a rectangle of the colour, not a
|
||||
// button holding one" rule. This row has a colour button in it and wants to
|
||||
// look like the ones under Customise.
|
||||
// stylesheet hangs the "a color button is a rectangle of the color, not a
|
||||
// button holding one" rule. This row has a color button in it and wants to
|
||||
// look like the ones under Customize.
|
||||
group.as(gtk.Widget).addCssClass("playpen-color-group");
|
||||
|
||||
// ---- the colour ----
|
||||
// ---- the color ----
|
||||
const row = gtk.Box.new(.horizontal, 8);
|
||||
row.as(gtk.Widget).addCssClass("playpen-color-row");
|
||||
|
||||
const label = gtk.Label.new("Base colour");
|
||||
const label = gtk.Label.new("Base color");
|
||||
label.setXalign(0);
|
||||
label.as(gtk.Widget).setHexpand(1);
|
||||
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
@@ -265,7 +265,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
|
||||
const dialog = gtk.ColorDialog.new();
|
||||
dialog.setWithAlpha(0);
|
||||
dialog.setModal(1);
|
||||
dialog.setTitle("Base colour");
|
||||
dialog.setTitle("Base color");
|
||||
|
||||
self.base = gtk.ColorDialogButton.new(dialog);
|
||||
self.base.as(gtk.Widget).setValign(.center);
|
||||
@@ -280,7 +280,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
|
||||
row.append(self.base.as(gtk.Widget));
|
||||
|
||||
self.base_clear.as(gtk.Widget).addCssClass("flat");
|
||||
self.base_clear.as(gtk.Widget).setTooltipText("Back to Playpen's own colours");
|
||||
self.base_clear.as(gtk.Widget).setTooltipText("Back to Playpen's own colors");
|
||||
_ = gtk.Button.signals.clicked.connect(
|
||||
self.base_clear,
|
||||
*PaletteEditor,
|
||||
@@ -293,15 +293,15 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
|
||||
group.append(row.as(gtk.Widget));
|
||||
group.append(hint(
|
||||
"Pick one and the rest follows: a very dark version of it behind the window, " ++
|
||||
"lighter ones for the panes and the text on them, and the colour itself as the accent. " ++
|
||||
"Anything you set under Customise stays where you put it.",
|
||||
"lighter ones for the panes and the text on them, and the color itself as the accent. " ++
|
||||
"Anything you set under Customize stays where you put it.",
|
||||
));
|
||||
|
||||
// ---- how far apart ----
|
||||
const contrast_row = gtk.Box.new(.horizontal, 8);
|
||||
contrast_row.as(gtk.Widget).addCssClass("playpen-color-row");
|
||||
contrast_row.as(gtk.Widget).setTooltipText(
|
||||
"How far apart to spread the colours built from the base.\n" ++
|
||||
"How far apart to spread the colors built from the base.\n" ++
|
||||
"Starker pulls the backdrop darker and the text brighter; softer draws them together.",
|
||||
);
|
||||
|
||||
@@ -324,7 +324,7 @@ fn buildBase(self: *PaletteEditor) *gtk.Widget {
|
||||
self.contrast.as(gtk.Range).setIncrements(0.05, 0.25);
|
||||
self.contrast.as(gtk.Range).setRoundDigits(2);
|
||||
|
||||
// The unlabelled middle mark is what makes the shipped spacing findable
|
||||
// The unlabeled middle mark is what makes the shipped spacing findable
|
||||
// again after a drag: GTK snaps the handle to a mark it passes near.
|
||||
self.contrast.addMark(-1, .bottom, "Soft");
|
||||
self.contrast.addMark(0, .bottom, null);
|
||||
@@ -350,8 +350,8 @@ fn buildGroups(self: *PaletteEditor) void {
|
||||
var current: ?palette.Group = null;
|
||||
var section: *gtk.Box = undefined;
|
||||
|
||||
// The ANSI colours are a grid of bare swatches rather than sixteen labelled
|
||||
// rows: they are a palette people recognise by position — eight normal, then
|
||||
// The ANSI colors are a grid of bare swatches rather than sixteen labeled
|
||||
// rows: they are a palette people recognize by position — eight normal, then
|
||||
// eight bright — and a list of their names says less than the strip does.
|
||||
var ansi: *gtk.Grid = undefined;
|
||||
|
||||
@@ -396,7 +396,7 @@ fn buildGroupHeader(self: *PaletteEditor, group: palette.Group) *gtk.Widget {
|
||||
|
||||
const reset = gtk.Button.newFromIconName("edit-undo-symbolic");
|
||||
reset.as(gtk.Widget).addCssClass("flat");
|
||||
reset.as(gtk.Widget).setTooltipText("Reset the colours in this section");
|
||||
reset.as(gtk.Widget).setTooltipText("Reset the colors in this section");
|
||||
|
||||
const header = &self.groups[@intFromEnum(group)];
|
||||
header.* = .{ .editor = self, .group = group, .reset = reset };
|
||||
@@ -407,7 +407,7 @@ fn buildGroupHeader(self: *PaletteEditor, group: palette.Group) *gtk.Widget {
|
||||
return row.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// One colour as a row: what it is, what it currently is, and the two controls
|
||||
/// One color as a row: what it is, what it currently is, and the two controls
|
||||
/// for changing that.
|
||||
fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget {
|
||||
const row = gtk.Box.new(.horizontal, 8);
|
||||
@@ -419,7 +419,7 @@ fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget {
|
||||
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
row.append(label.as(gtk.Widget));
|
||||
|
||||
// The tooltip carries what the colour is *for*, plus the name it goes by in
|
||||
// The tooltip carries what the color is *for*, plus the name it goes by in
|
||||
// the settings file, since anyone hand-editing that file is looking at this
|
||||
// list to find out what to call things.
|
||||
var tip: [256]u8 = undefined;
|
||||
@@ -454,8 +454,8 @@ fn buildRow(self: *PaletteEditor, key: palette.Key) *gtk.Widget {
|
||||
/// A swatch, and the row record that goes with it.
|
||||
///
|
||||
/// Alpha is off: `style.css` derives every wash it needs from `alpha()` on the
|
||||
/// colour it belongs to, so a translucent palette entry would be a second way
|
||||
/// of saying the same thing, and one the terminal renderer could not honour.
|
||||
/// color it belongs to, so a translucent palette entry would be a second way
|
||||
/// of saying the same thing, and one the terminal renderer could not honor.
|
||||
fn newSwatch(
|
||||
self: *PaletteEditor,
|
||||
key: palette.Key,
|
||||
@@ -507,7 +507,7 @@ fn reload(self: *PaletteEditor) void {
|
||||
|
||||
// With no base of its own, the swatch shows the accent this scheme is
|
||||
// already painted with — so the first click on it starts from the theme in
|
||||
// front of you rather than from whatever colour a picker opens on.
|
||||
// front of you rather than from whatever color a picker opens on.
|
||||
const base = if (changes.tint) |tint|
|
||||
tint.base
|
||||
else
|
||||
@@ -524,9 +524,9 @@ fn reload(self: *PaletteEditor) void {
|
||||
self.contrast.as(gtk.Widget).setSensitive(@intFromBool(changes.tint != null));
|
||||
|
||||
for (&self.rows) |*row| {
|
||||
// The colour, not the override: a terminal colour that is following the
|
||||
// surface above it, or any colour at all under a base, has to show the
|
||||
// colour it is actually painted in.
|
||||
// The color, not the override: a terminal color that is following the
|
||||
// surface above it, or any color at all under a base, has to show the
|
||||
// color it is actually painted in.
|
||||
const color = palette.resolve(row.key, scheme, changes);
|
||||
|
||||
var rgba = toRgba(color);
|
||||
@@ -538,7 +538,7 @@ fn reload(self: *PaletteEditor) void {
|
||||
}
|
||||
|
||||
// Whether there is anything to revert *to*, which is a different
|
||||
// question: neither an inherited colour nor a generated one is a colour
|
||||
// question: neither an inherited color nor a generated one is a color
|
||||
// that has been set.
|
||||
if (row.revert) |button| {
|
||||
button.as(gtk.Widget).setSensitive(@intFromBool(changes.overrides.get(row.key) != null));
|
||||
@@ -565,7 +565,7 @@ fn groupIsSet(group: palette.Group, overrides: *const palette.Overrides) bool {
|
||||
///
|
||||
/// In that order on purpose: repainting is what the user asked for and cannot
|
||||
/// fail, while the write can — and a settings file that couldn't be written is
|
||||
/// worth saying so about without also refusing the colour for this session.
|
||||
/// worth saying so about without also refusing the color for this session.
|
||||
fn applied(self: *PaletteEditor) void {
|
||||
appearance.refresh();
|
||||
self.reload();
|
||||
@@ -580,7 +580,7 @@ fn applied(self: *PaletteEditor) void {
|
||||
/// Show the change and leave the file for later.
|
||||
///
|
||||
/// For the contrast slider alone. Every other control here settles on a value
|
||||
/// once — a colour dialog reports when it is dismissed, a button when it is
|
||||
/// once — a color dialog reports when it is dismissed, a button when it is
|
||||
/// clicked — but a slider reports on every pixel of a drag, and writing the
|
||||
/// settings file at that rate would be an fsync per frame of an animation. The
|
||||
/// settings page writes on close, so nothing is lost by waiting; see the note at
|
||||
@@ -608,7 +608,7 @@ fn onColorPicked(
|
||||
// The scheme is read here rather than remembered from the last reload: a
|
||||
// desktop that flips to light under a `system` preference while this page is
|
||||
// open changes which palette an edit belongs to, and writing to the one the
|
||||
// rows were last filled from would put the colour in the palette that isn't
|
||||
// rows were last filled from would put the color in the palette that isn't
|
||||
// on screen.
|
||||
const scheme = theme.currentScheme();
|
||||
Settings.get().colors.of(scheme).overrides.set(row.key, fromRgba(swatch.getRgba()));
|
||||
@@ -660,9 +660,9 @@ fn onBasePicked(
|
||||
}
|
||||
|
||||
fn onBaseCleared(_: *gtk.Button, self: *PaletteEditor) callconv(.c) void {
|
||||
// Only the generated palette goes. Colours set by hand were set against
|
||||
// Only the generated palette goes. Colors set by hand were set against
|
||||
// what was underneath them and are still what their owner asked for, so
|
||||
// dropping those too is `onReset`'s job and is labelled as such.
|
||||
// dropping those too is `onReset`'s job and is labeled as such.
|
||||
Settings.get().colors.of(theme.currentScheme()).tint = null;
|
||||
self.applied();
|
||||
}
|
||||
@@ -702,10 +702,10 @@ fn toRgba(color: palette.Rgb) gdk.RGBA {
|
||||
};
|
||||
}
|
||||
|
||||
/// GTK works in floats and the palette works in bytes, so a colour that came
|
||||
/// GTK works in floats and the palette works in bytes, so a color that came
|
||||
/// out of the picker's own HSV wheel or its eyedropper has to be rounded to
|
||||
/// something a hex triplet can hold. Rounding rather than truncating, so that a
|
||||
/// colour typed in as hex comes back as the same hex.
|
||||
/// color typed in as hex comes back as the same hex.
|
||||
fn fromRgba(rgba: *const gdk.RGBA) palette.Rgb {
|
||||
return .{
|
||||
.r = channel(rgba.f_red),
|
||||
|
||||
+10
-10
@@ -136,7 +136,7 @@ const all_sides = [_]Side{ .left, .right, .top, .bottom };
|
||||
// Status dots
|
||||
//
|
||||
// Both the pane header and the sidebar row show the same small filled circle,
|
||||
// recoloured by CSS. Symbolic icons take their colour from the `color`
|
||||
// recolored by CSS. Symbolic icons take their color from the `color`
|
||||
// property, so the state is carried entirely by which class is applied and
|
||||
// there is no second icon to keep in sync.
|
||||
|
||||
@@ -159,8 +159,8 @@ pub const status_classes = [_][:0]const u8{
|
||||
|
||||
/// Point a dot at one of `status_classes`, or pass null to hide it.
|
||||
///
|
||||
/// Hiding rather than clearing the colour matters: an idle pane should show
|
||||
/// nothing at all, not a dot in the background colour that still takes up
|
||||
/// Hiding rather than clearing the color matters: an idle pane should show
|
||||
/// nothing at all, not a dot in the background color that still takes up
|
||||
/// space and still catches the eye when the row is hovered.
|
||||
pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void {
|
||||
const w = dot.as(gtk.Widget);
|
||||
@@ -174,7 +174,7 @@ pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a dot is signalling: the four states content can report, plus one it
|
||||
/// What a dot is signaling: the four states content can report, plus one it
|
||||
/// cannot know about on its own.
|
||||
///
|
||||
/// `done` is the whole reason this is a separate type from `Status`. Content
|
||||
@@ -212,7 +212,7 @@ pub const Attention = enum {
|
||||
/// carrying it — a sidebar row, or a pane's frame.
|
||||
///
|
||||
/// Returned as a pair so the two can never drift into disagreeing about
|
||||
/// what colour a state is. Null means idle, which paints nothing at all.
|
||||
/// what color a state is. Null means idle, which paints nothing at all.
|
||||
pub fn classes(self: Attention) ?struct {
|
||||
dot: [:0]const u8,
|
||||
surface: [:0]const u8,
|
||||
@@ -255,8 +255,8 @@ pub const attention_classes = [_][:0]const u8{
|
||||
"playpen-attn-failed",
|
||||
};
|
||||
|
||||
/// Bring a dot in line with what it should be signalling. The tooltip comes
|
||||
/// along with the colour, since a colour on its own doesn't say what it means.
|
||||
/// Bring a dot in line with what it should be signaling. The tooltip comes
|
||||
/// along with the color, since a color on its own doesn't say what it means.
|
||||
pub fn applyDot(dot: *gtk.Image, state: Attention) void {
|
||||
setDot(dot, state.class());
|
||||
dot.as(gtk.Widget).setTooltipText(state.tooltip());
|
||||
@@ -267,7 +267,7 @@ pub fn applyDot(dot: *gtk.Image, state: Attention) void {
|
||||
/// A dot alone is a few pixels, which is fine once you know where to look and
|
||||
/// no use at all for the thing this is for: sweeping a sidebar and picking out
|
||||
/// the row that wants you. The surface class is what lets the row and the pane
|
||||
/// frame carry the same colour at a size you can catch out of the corner of
|
||||
/// frame carry the same color at a size you can catch out of the corner of
|
||||
/// your eye.
|
||||
pub fn applyAttention(surface: *gtk.Widget, dot: *gtk.Image, state: Attention) void {
|
||||
applyDot(dot, state);
|
||||
@@ -435,7 +435,7 @@ pub fn titleSlice(self: *const Pane) [:0]const u8 {
|
||||
return std.mem.sliceTo(&self.title, 0);
|
||||
}
|
||||
|
||||
/// What this pane's dot is signalling right now.
|
||||
/// What this pane's dot is signaling right now.
|
||||
pub fn attention(self: *const Pane) Attention {
|
||||
return .of(self.status, self.done_unanswered);
|
||||
}
|
||||
@@ -608,7 +608,7 @@ fn installDropTarget(self: *Pane) void {
|
||||
/// Work out where a drop at this point would put the dragged pane.
|
||||
///
|
||||
/// The side is whichever edge of this pane the pointer is nearest, so every
|
||||
/// point maps to some edge and there is no dead centre. If that point is also
|
||||
/// point maps to some edge and there is no dead center. If that point is also
|
||||
/// close to the matching border of the view itself, the drop is promoted to
|
||||
/// span the whole layout instead of splitting just this pane — dropping along
|
||||
/// the bottom of the window gives a pane stretching across the bottom, while
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
//! the matches agree, the arrow keys pick out of the list, and `Enter` takes
|
||||
//! the highlighted one or, with nothing highlighted, means what it means
|
||||
//! everywhere else in the dialog. `GtkEntryCompletion` would have been the
|
||||
//! stock answer and is deprecated as of GTK 4.10, and its inline behaviour was
|
||||
//! stock answer and is deprecated as of GTK 4.10, and its inline behavior was
|
||||
//! never this: it completes to the *first* match rather than to the longest
|
||||
//! common prefix, which in a directory of siblings guesses wrong more often
|
||||
//! than it helps.
|
||||
@@ -455,7 +455,7 @@ fn onFolderChosen(
|
||||
var err: ?*glib.Error = null;
|
||||
const file = dialog.selectFolderFinish(result, &err) orelse {
|
||||
// Dismissing the chooser is how most trips through it end, and
|
||||
// cancelling it is how a field that has gone away ends one, so neither
|
||||
// canceling it is how a field that has gone away ends one, so neither
|
||||
// is worth a word.
|
||||
if (err) |e| e.free();
|
||||
return;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//!
|
||||
//! The tree is allocated in the layout store's arena in both cases, so saving
|
||||
//! writes the edited strings straight back into its leaves. Nothing is written
|
||||
//! until the confirm button, so cancelling an edit leaves the layout alone.
|
||||
//! until the confirm button, so canceling an edit leaves the layout alone.
|
||||
|
||||
const std = @import("std");
|
||||
const gio = @import("gio");
|
||||
|
||||
+2
-2
@@ -317,7 +317,7 @@ fn EffectReply(comptime name: []const u8) type {
|
||||
}
|
||||
|
||||
/// Effect callback: the child asked what kind of terminal this is (CSI c and
|
||||
/// friends). The defaults describe a VT220 that does ANSI colour, which is
|
||||
/// friends). The defaults describe a VT220 that does ANSI color, which is
|
||||
/// what this actually is — the answer matters less than there being one.
|
||||
fn effectDeviceAttributes(
|
||||
_: *vt.TerminalStream.Handler,
|
||||
@@ -326,7 +326,7 @@ fn effectDeviceAttributes(
|
||||
}
|
||||
|
||||
/// Effect callback: the child asked whether it is drawing on a light or dark
|
||||
/// background (CSI ? 996 n), so it can pick readable colours. Answered from
|
||||
/// background (CSI ? 996 n), so it can pick readable colors. Answered from
|
||||
/// the scheme the grid is actually painted in.
|
||||
fn effectColorScheme(
|
||||
_: *vt.TerminalStream.Handler,
|
||||
|
||||
+69
-27
@@ -4,8 +4,8 @@
|
||||
//! something changes. It carries a version from the start, so adding the next
|
||||
//! setting is not also the day we invent a format.
|
||||
//!
|
||||
//! The settings are three different shapes. The colour scheme is one word. The
|
||||
//! **palette changes** are a base colour per scheme, plus a colour per name for
|
||||
//! The settings are three different shapes. The color scheme is one word. The
|
||||
//! **palette changes** are a base color per scheme, plus a color per name for
|
||||
//! the names someone has gone on to change by hand. The **startup tabs** are a
|
||||
//! list: which
|
||||
//! saved layout to open, what to fill its parameters in with, and what to call
|
||||
@@ -15,9 +15,9 @@
|
||||
//! field.
|
||||
//!
|
||||
//! The palette changes deliberately stay out of that arena. They are a base
|
||||
//! colour and a fixed-size array of optionals, which needs no allocation at
|
||||
//! color and a fixed-size array of optionals, which needs no allocation at
|
||||
//! all — and, more to the
|
||||
//! point, `setStartup` replaces the arena wholesale, so anything colour-shaped
|
||||
//! point, `setStartup` replaces the arena wholesale, so anything color-shaped
|
||||
//! living in it would be freed by an unrelated edit to the startup list. The
|
||||
//! **shortcut overrides** are the same shape for the same reason: a chord is a
|
||||
//! key and four flags once it has been parsed, so nothing about it needs to
|
||||
@@ -73,8 +73,8 @@ pub const Theme = enum {
|
||||
}
|
||||
};
|
||||
|
||||
/// What someone has done to the palette, per scheme: the colour it is generated
|
||||
/// from, and the individual colours they have set by hand.
|
||||
/// What someone has done to the palette, per scheme: the color it is generated
|
||||
/// from, and the individual colors they have set by hand.
|
||||
///
|
||||
/// Both schemes are kept, not just the one in force: the editor changes the
|
||||
/// palette you can see, and pinning the app light for an afternoon shouldn't
|
||||
@@ -137,8 +137,17 @@ theme: Theme = .system,
|
||||
/// a mistyped chord and every shell in every tab exiting.
|
||||
confirm_quit: bool = true,
|
||||
|
||||
/// Changes to the palette. Empty means every colour is at its default, which is
|
||||
/// the state anyone who never opens the colour editor stays in — and it is the
|
||||
/// Whether a pane finishing posts a desktop notification.
|
||||
///
|
||||
/// On by default. The whole shape of this app is "set something going in a tab
|
||||
/// and go and do something else", and a sidebar dot can only tell you that
|
||||
/// while you are looking at the sidebar. Someone who does not want to be told
|
||||
/// has a switch in the settings and a per-tab mute in every row menu; someone
|
||||
/// who does want to be told should not have to find a setting first.
|
||||
notifications: bool = true,
|
||||
|
||||
/// Changes to the palette. Empty means every color is at its default, which is
|
||||
/// the state anyone who never opens the color editor stays in — and it is the
|
||||
/// reason the defaults can be retuned in a later version and still reach them.
|
||||
colors: Colors = .{},
|
||||
|
||||
@@ -183,7 +192,7 @@ pub fn deinit() void {
|
||||
|
||||
/// Read the settings file, falling back to defaults for anything missing.
|
||||
///
|
||||
/// Every failure below lands on the same behaviour — carry on with defaults —
|
||||
/// Every failure below lands on the same behavior — carry on with defaults —
|
||||
/// because there is no useful alternative: this runs before there is a window
|
||||
/// to report an error in, and refusing to start over an unreadable preferences
|
||||
/// file would be a worse outcome than ignoring it. A malformed file is logged
|
||||
@@ -259,6 +268,10 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
|
||||
if (value == .bool) self.confirm_quit = value.bool;
|
||||
}
|
||||
|
||||
if (root.get("notifications")) |value| {
|
||||
if (value == .bool) self.notifications = value.bool;
|
||||
}
|
||||
|
||||
if (root.get("colors")) |value| self.parseColors(value);
|
||||
|
||||
if (root.get("tint")) |value| self.parseTint(value);
|
||||
@@ -270,8 +283,8 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
|
||||
|
||||
/// Read the palette overrides: `{"dark": {"accent": "#ff8800"}, "light": {…}}`.
|
||||
///
|
||||
/// Nothing here can fail the file. A colour is one word of a palette, and the
|
||||
/// answer to a word we don't recognise or can't parse is the default for it —
|
||||
/// Nothing here can fail the file. A color is one word of a palette, and the
|
||||
/// answer to a word we don't recognize or can't parse is the default for it —
|
||||
/// which is exactly what an absent key already means, so an unknown name from a
|
||||
/// newer version and a typo in a hand-edited file cost the same nothing.
|
||||
fn parseColors(self: *Settings, raw: std.json.Value) void {
|
||||
@@ -298,7 +311,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
|
||||
var it = colors.iterator();
|
||||
while (it.next()) |kv| {
|
||||
const key = std.meta.stringToEnum(palette.Key, kv.key_ptr.*) orelse {
|
||||
std.log.warn("settings: no colour called \"{s}\"; ignoring it", .{kv.key_ptr.*});
|
||||
std.log.warn("settings: no color called \"{s}\"; ignoring it", .{kv.key_ptr.*});
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -313,7 +326,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
|
||||
};
|
||||
|
||||
const color = palette.Rgb.parse(text) orelse {
|
||||
std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text});
|
||||
std.log.warn("settings: \"{s}\" is not a #rrggbb color", .{text});
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -325,7 +338,7 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
|
||||
/// Read the generated palettes: `{"dark": {"base": "#3a76f0", "contrast": 0.5}}`.
|
||||
///
|
||||
/// A scheme without a `base` has no generated palette at all, whatever else the
|
||||
/// entry says — the contrast is how far apart to spread the colours built from
|
||||
/// entry says — the contrast is how far apart to spread the colors built from
|
||||
/// the base, so on its own it describes nothing. That is also why a malformed
|
||||
/// contrast costs only the contrast: the base is the setting, and dropping the
|
||||
/// whole theme over one bad number would be the wrong trade.
|
||||
@@ -356,7 +369,7 @@ fn parseTint(self: *Settings, raw: std.json.Value) void {
|
||||
};
|
||||
|
||||
const base = palette.Rgb.parse(text) orelse {
|
||||
std.log.warn("settings: \"{s}\" is not a #rrggbb colour", .{text});
|
||||
std.log.warn("settings: \"{s}\" is not a #rrggbb color", .{text});
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -386,7 +399,7 @@ fn parseTint(self: *Settings, raw: std.json.Value) void {
|
||||
/// list — or `null` — is how a shortcut is turned off, which is worth having:
|
||||
/// the chords here are ones a program running in the terminal may want back.
|
||||
///
|
||||
/// Nothing here can fail the file, on the same grounds as the colours above. An
|
||||
/// Nothing here can fail the file, on the same grounds as the colors above. An
|
||||
/// action this build doesn't have, or a chord that doesn't parse, costs that one
|
||||
/// binding and is logged; what it falls back to is the shipped default, which is
|
||||
/// exactly what an absent entry already means.
|
||||
@@ -610,8 +623,10 @@ fn serialize(self: *Settings) SaveError![]u8 {
|
||||
try json.write(@tagName(self.theme));
|
||||
try json.objectField("confirm_quit");
|
||||
try json.write(self.confirm_quit);
|
||||
try json.objectField("notifications");
|
||||
try json.write(self.notifications);
|
||||
|
||||
// Only the colours that have been changed, and only the schemes that have
|
||||
// Only the colors that have been changed, and only the schemes that have
|
||||
// any. The object itself is written even when empty, for the same reason
|
||||
// the startup list below is: a file you have opened to see what you can put
|
||||
// in it should name what it accepts.
|
||||
@@ -636,7 +651,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
|
||||
}
|
||||
try json.endObject();
|
||||
|
||||
// And the base colour each scheme's palette is generated from, for the
|
||||
// And the base color each scheme's palette is generated from, for the
|
||||
// schemes that have one. Written after the overrides rather than before
|
||||
// because that is the order they apply in, and a settings file is read by
|
||||
// people as well as by this.
|
||||
@@ -657,7 +672,7 @@ fn serialize(self: *Settings) SaveError![]u8 {
|
||||
try json.endObject();
|
||||
|
||||
// Only the shortcuts that have been rebound, for the same reasons as the
|
||||
// colours — and with the same consequence, which is the one that matters
|
||||
// colors — and with the same consequence, which is the one that matters
|
||||
// here: saving rewrites the whole file, so a hand-edited shortcut has to
|
||||
// come back out of this function or changing the theme would delete it.
|
||||
// Chords are written from the parsed form rather than the text they came
|
||||
@@ -738,7 +753,7 @@ fn configDir(buf: []u8) ?[:0]const u8 {
|
||||
//
|
||||
// Reading and writing the file, minus the file: `parse` and `serialize` are
|
||||
// where the on-disk shape actually lives, and they need nothing but an
|
||||
// allocator. The palette overrides are what these are here for — a colour is a
|
||||
// allocator. The palette overrides are what these are here for — a color is a
|
||||
// name and a hex string in a nested object, which is three chances to write out
|
||||
// something the next launch reads back as a different palette.
|
||||
|
||||
@@ -768,6 +783,33 @@ test "confirming a quit is on unless the file turns it off" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "\"confirm_quit\": false") != null);
|
||||
}
|
||||
|
||||
test "notifications are on unless the file turns them off" {
|
||||
var settings = forTesting();
|
||||
defer settings.arena.deinit();
|
||||
|
||||
try std.testing.expect(settings.notifications);
|
||||
|
||||
try settings.parse(
|
||||
\\{"notifications": false}
|
||||
);
|
||||
try std.testing.expect(!settings.notifications);
|
||||
|
||||
const text = try settings.serialize();
|
||||
defer std.testing.allocator.free(text);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "\"notifications\": false") != null);
|
||||
|
||||
// And unreadable is the same as absent, which for this flag means on: the
|
||||
// failure someone would report is "it stopped telling me", not "it told me
|
||||
// and I had typed nonsense into the file".
|
||||
var typo = forTesting();
|
||||
defer typo.arena.deinit();
|
||||
try typo.parse(
|
||||
\\{"notifications": "off"}
|
||||
);
|
||||
try std.testing.expect(typo.notifications);
|
||||
}
|
||||
|
||||
test "a confirm_quit we can't read leaves the confirmation up" {
|
||||
var settings = forTesting();
|
||||
defer settings.arena.deinit();
|
||||
@@ -797,19 +839,19 @@ test "palette overrides are read per scheme" {
|
||||
try std.testing.expectEqual(palette.Rgb.parse("#112233").?, settings.colors.dark.overrides.get(.ansi_red).?);
|
||||
try std.testing.expectEqual(palette.Rgb.parse("#fafafa").?, settings.colors.light.overrides.get(.sidebar).?);
|
||||
|
||||
// A scheme that says nothing about a colour leaves it at its default, which
|
||||
// A scheme that says nothing about a color leaves it at its default, which
|
||||
// is what an absent override means everywhere else.
|
||||
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.light.overrides.get(.accent));
|
||||
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
|
||||
}
|
||||
|
||||
test "a colour we can't read costs that colour and nothing else" {
|
||||
test "a color we can't read costs that color and nothing else" {
|
||||
var settings = forTesting();
|
||||
defer settings.arena.deinit();
|
||||
|
||||
try settings.parse(
|
||||
\\{"theme": "dark", "colors": {"dark": {
|
||||
\\ "not_a_colour": "#123456",
|
||||
\\ "not_a_color": "#123456",
|
||||
\\ "border": "rebeccapurple",
|
||||
\\ "sidebar": 12,
|
||||
\\ "accent": "#ff8800"
|
||||
@@ -819,8 +861,8 @@ test "a colour we can't read costs that colour and nothing else" {
|
||||
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.border));
|
||||
try std.testing.expectEqual(@as(?palette.Rgb, null), settings.colors.dark.overrides.get(.sidebar));
|
||||
|
||||
// The whole file survives, including the theme above the colours and the
|
||||
// one colour in the list that was well formed.
|
||||
// The whole file survives, including the theme above the colors and the
|
||||
// one color in the list that was well formed.
|
||||
try std.testing.expectEqual(Theme.dark, settings.theme);
|
||||
try std.testing.expectEqual(palette.Rgb.parse("#ff8800").?, settings.colors.dark.overrides.get(.accent).?);
|
||||
}
|
||||
@@ -835,7 +877,7 @@ test "colors is written even when nothing has been changed" {
|
||||
try std.testing.expect(std.mem.indexOf(u8, text, "\"colors\": {}") != null);
|
||||
}
|
||||
|
||||
test "only the changed colours are written, and they come back" {
|
||||
test "only the changed colors are written, and they come back" {
|
||||
var settings = forTesting();
|
||||
defer settings.arena.deinit();
|
||||
|
||||
@@ -864,7 +906,7 @@ test "only the changed colours are written, and they come back" {
|
||||
}
|
||||
}
|
||||
|
||||
test "a base colour and its contrast survive the file" {
|
||||
test "a base color and its contrast survive the file" {
|
||||
var settings = forTesting();
|
||||
defer settings.arena.deinit();
|
||||
|
||||
|
||||
+77
-3
@@ -109,6 +109,9 @@ colors: *PaletteEditor,
|
||||
/// one place and read in another; nothing re-checks it.
|
||||
confirm_quit: *gtk.Switch,
|
||||
|
||||
/// The notifications toggle, held for the same reason.
|
||||
notifications: *gtk.Switch,
|
||||
|
||||
/// Container the startup rows live in, so rows can be added and removed after
|
||||
/// the page is already on screen.
|
||||
startup_box: *gtk.Box,
|
||||
@@ -145,6 +148,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo
|
||||
.theme_buttons = undefined,
|
||||
.colors = undefined,
|
||||
.confirm_quit = gtk.Switch.new(),
|
||||
.notifications = gtk.Switch.new(),
|
||||
.startup_box = gtk.Box.new(.vertical, 6),
|
||||
.error_label = gtk.Label.new(null),
|
||||
};
|
||||
@@ -165,6 +169,7 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !vo
|
||||
content.as(gtk.Widget).addCssClass("playpen-dialog-content");
|
||||
|
||||
content.append(self.buildAppearance());
|
||||
content.append(self.buildNotifications());
|
||||
content.append(self.buildQuitting());
|
||||
content.append(self.buildStartup());
|
||||
|
||||
@@ -219,7 +224,7 @@ pub fn close() void {
|
||||
// -------------------------------------------------------------------------
|
||||
// Appearance
|
||||
|
||||
/// The Appearance section: the colour scheme, and the palette it resolves to.
|
||||
/// The Appearance section: the color scheme, and the palette it resolves to.
|
||||
fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
|
||||
const group = gtk.Box.new(.vertical, 10);
|
||||
group.as(gtk.Widget).addCssClass("playpen-settings-group");
|
||||
@@ -266,7 +271,7 @@ fn onEditorReport(ctx: ?*anyopaque, message: ?[:0]const u8) void {
|
||||
/// The scheme picker: one toggle per choice, drawn as a single linked control.
|
||||
///
|
||||
/// They are not put in a GTK radio group. Grouped toggles can't be unchecked by
|
||||
/// clicking the active one, which is the behaviour we want, but the group also
|
||||
/// clicking the active one, which is the behavior we want, but the group also
|
||||
/// emits `toggled` twice per change — once off, once on — and the off half
|
||||
/// would apply whichever scheme happened to be next in the list. Handling the
|
||||
/// exclusivity here instead makes each click exactly one decision.
|
||||
@@ -326,13 +331,82 @@ fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c)
|
||||
appearance.setTheme(values[index]);
|
||||
|
||||
// The palette editor edits whichever scheme is on screen, and that is what
|
||||
// just changed: its swatches now stand for the other palette's colours.
|
||||
// just changed: its swatches now stand for the other palette's colors.
|
||||
self.colors.refresh();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Quitting
|
||||
|
||||
/// The Notifications section: whether a finish reaches the desktop.
|
||||
///
|
||||
/// The whole-app half of the mute. The per-tab half lives in each row's menu,
|
||||
/// because it is a thing you decide about one tab while looking at it, and
|
||||
/// walking to a settings window to say "not this one, for the next hour" would
|
||||
/// mean nobody ever did.
|
||||
fn buildNotifications(self: *SettingsDialog) *gtk.Widget {
|
||||
const group = gtk.Box.new(.vertical, 10);
|
||||
group.as(gtk.Widget).addCssClass("playpen-settings-group");
|
||||
|
||||
const title = gtk.Label.new("Notifications");
|
||||
title.setXalign(0);
|
||||
title.as(gtk.Widget).addCssClass("playpen-settings-title");
|
||||
group.append(title.as(gtk.Widget));
|
||||
|
||||
const row = gtk.Box.new(.horizontal, 12);
|
||||
|
||||
const labels = gtk.Box.new(.vertical, 2);
|
||||
labels.as(gtk.Widget).setHexpand(1);
|
||||
labels.as(gtk.Widget).setValign(.center);
|
||||
|
||||
const name = gtk.Label.new("Tell me when a pane finishes");
|
||||
name.setXalign(0);
|
||||
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
labels.append(name.as(gtk.Widget));
|
||||
|
||||
const hint_label = gtk.Label.new(
|
||||
"A desktop notification when something running in a tab goes quiet, " ++
|
||||
"for the tabs you aren't looking at. Off, the sidebar dots are the " ++
|
||||
"only word you get. One tab at a time can be muted from its row menu.",
|
||||
);
|
||||
hint_label.setXalign(0);
|
||||
hint_label.setWrap(1);
|
||||
hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
|
||||
labels.append(hint_label.as(gtk.Widget));
|
||||
|
||||
row.append(labels.as(gtk.Widget));
|
||||
|
||||
self.notifications.setActive(@intFromBool(Settings.get().notifications));
|
||||
self.notifications.as(gtk.Widget).setValign(.center);
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
self.notifications,
|
||||
*SettingsDialog,
|
||||
&onNotificationsChanged,
|
||||
self,
|
||||
.{ .detail = "active" },
|
||||
);
|
||||
row.append(self.notifications.as(gtk.Widget));
|
||||
|
||||
group.append(row.as(gtk.Widget));
|
||||
|
||||
return group.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Straight to the file, for the same reason `onConfirmQuitChanged` is.
|
||||
fn onNotificationsChanged(
|
||||
toggle: *gtk.Switch,
|
||||
_: *gobject.ParamSpec,
|
||||
self: *SettingsDialog,
|
||||
) callconv(.c) void {
|
||||
Settings.get().notifications = toggle.getActive() != 0;
|
||||
|
||||
Settings.get().save() catch {
|
||||
self.showError("Could not write the settings file.");
|
||||
return;
|
||||
};
|
||||
self.showError(null);
|
||||
}
|
||||
|
||||
/// The Quitting section: whether closing the window asks first.
|
||||
///
|
||||
/// Above the startup list rather than below it. That list grows with the number
|
||||
|
||||
+1
-1
@@ -712,7 +712,7 @@ fn selectPress(self: *Terminal, x: f64, y: f64) void {
|
||||
const term = &self.session.term;
|
||||
const pin = self.pinAt(x, y) orelse return;
|
||||
|
||||
// The gesture counts the clicks and picks the behaviour: one selects by
|
||||
// The gesture counts the clicks and picks the behavior: one selects by
|
||||
// cell, two by word, three by line.
|
||||
const sel = self.gesture.press(term, .{
|
||||
.time = now(),
|
||||
|
||||
+16
-12
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! Dragging a pane rearranges the view live rather than on release. Each time
|
||||
//! the drop target changes, the move is applied for real, so what you see
|
||||
//! during the drag is the layout you will get. If the drag is cancelled the
|
||||
//! during the drag is the layout you will get. If the drag is canceled the
|
||||
//! pane goes back where it came from, which is possible because only the
|
||||
//! dragged pane ever moves: the rest of the tree is unchanged by a move, so
|
||||
//! re-inserting it next to its original sibling restores the original shape.
|
||||
@@ -40,7 +40,7 @@ pub const Target = struct {
|
||||
const Drag = struct {
|
||||
pane: *Pane,
|
||||
|
||||
/// Where the pane sat when the drag began, so a cancelled drag can put
|
||||
/// Where the pane sat when the drag began, so a canceled drag can put
|
||||
/// it back.
|
||||
origin_sibling: *Layout.Node,
|
||||
origin_side: Side,
|
||||
@@ -50,7 +50,7 @@ const Drag = struct {
|
||||
previewed: ?Target = null,
|
||||
|
||||
/// Set once a drop has been accepted; a drag that ends without this was
|
||||
/// cancelled and must be undone.
|
||||
/// canceled and must be undone.
|
||||
committed: bool = false,
|
||||
};
|
||||
|
||||
@@ -101,7 +101,12 @@ on_status: *const fn (ctx: ?*anyopaque) void,
|
||||
/// last here" cannot be recovered by looking at the panes afterwards, since
|
||||
/// one that finished before your last visit looks identical to one that
|
||||
/// finished after it.
|
||||
on_finished: *const fn (ctx: ?*anyopaque) void,
|
||||
///
|
||||
/// Carries the finished pane's title, which is what a desktop notification says
|
||||
/// under the tab's name. It comes with the edge rather than being read off the
|
||||
/// view afterwards for the same reason the edge does: in a split, the pane that
|
||||
/// finished is not necessarily the one whose title the tab is wearing.
|
||||
on_finished: *const fn (ctx: ?*anyopaque, task: []const u8) void,
|
||||
|
||||
/// Something in here asked for a review pane.
|
||||
///
|
||||
@@ -115,7 +120,7 @@ pub const Callbacks = struct {
|
||||
on_empty: *const fn (ctx: ?*anyopaque) void,
|
||||
on_title: *const fn (ctx: ?*anyopaque) void,
|
||||
on_status: *const fn (ctx: ?*anyopaque) void,
|
||||
on_finished: *const fn (ctx: ?*anyopaque) void,
|
||||
on_finished: *const fn (ctx: ?*anyopaque, task: []const u8) void,
|
||||
on_review: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
@@ -167,7 +172,7 @@ pub fn widget(self: *View) *gtk.Widget {
|
||||
// arrangement rather than rebuilding an approximation of it.
|
||||
|
||||
/// Put the view on screen. Every structural change goes through here rather
|
||||
/// than calling the layout directly, so zoom is honoured from one place
|
||||
/// than calling the layout directly, so zoom is honored from one place
|
||||
/// instead of being re-checked at each call site.
|
||||
fn render(self: *View) void {
|
||||
if (self.zoomed) |pane| {
|
||||
@@ -418,9 +423,8 @@ pub fn setFocused(self: *View, pane: *Pane) void {
|
||||
}
|
||||
|
||||
pub fn paneFinished(self: *View, pane: *Pane) void {
|
||||
_ = pane;
|
||||
if (self.closing) return;
|
||||
self.on_finished(self.ctx);
|
||||
self.on_finished(self.ctx, pane.titleSlice());
|
||||
}
|
||||
|
||||
pub fn paneTitleChanged(self: *View, pane: *Pane) void {
|
||||
@@ -685,7 +689,7 @@ fn reattachAtRoot(self: *View, node: *Layout.Node) void {
|
||||
}
|
||||
|
||||
/// Move the focused pane one step in a direction: the keyboard equivalent of
|
||||
/// dragging it onto the neighbouring pane.
|
||||
/// dragging it onto the neighboring pane.
|
||||
pub fn moveFocused(self: *View, side: Side) void {
|
||||
const pane = self.focusedPane() orelse return;
|
||||
if (self.panes.items.len < 2) return;
|
||||
@@ -707,7 +711,7 @@ pub fn moveFocused(self: *View, side: Side) void {
|
||||
|
||||
/// Move focus one step in a direction, to whichever pane is sitting there.
|
||||
///
|
||||
/// The counterpart of `moveFocused`: same neighbour, but the pane stays where
|
||||
/// The counterpart of `moveFocused`: same neighbor, but the pane stays where
|
||||
/// it is and you go to it instead. Both are answered geometrically rather than
|
||||
/// by walking the split tree, so "the pane to the left" means the one that
|
||||
/// looks like it — a tree walk would have to pick between siblings and cousins,
|
||||
@@ -723,7 +727,7 @@ pub fn focusNeighbor(self: *View, side: Side) bool {
|
||||
// While zoomed there is only one pane on screen and the others are
|
||||
// unparented, so there is nothing to probe for and nothing to move to. The
|
||||
// arrangement is exactly what zoom is hiding; leaving it to hunt for a
|
||||
// neighbour would undo what the user asked for.
|
||||
// neighbor would undo what the user asked for.
|
||||
if (self.zoomed != null) return false;
|
||||
|
||||
const target = self.neighbor(pane, side) orelse return false;
|
||||
@@ -773,7 +777,7 @@ pub fn beginDrag(self: *View, pane: *Pane) bool {
|
||||
|
||||
// Only the zoomed pane is on screen, so there is nothing to drop against
|
||||
// and no preview to show. Refusing the drag is better than starting one
|
||||
// that can only ever be cancelled.
|
||||
// that can only ever be canceled.
|
||||
if (self.zoomed != null) return false;
|
||||
|
||||
const node = self.layout.find(pane) orelse return false;
|
||||
|
||||
+127
-21
@@ -28,6 +28,7 @@ const View = @import("View.zig");
|
||||
const appearance = @import("appearance.zig");
|
||||
const emoji = @import("emoji.zig");
|
||||
const key = @import("key.zig");
|
||||
const notify = @import("notify.zig");
|
||||
const review = @import("review.zig");
|
||||
const shortcuts = @import("shortcuts.zig");
|
||||
|
||||
@@ -132,7 +133,7 @@ layout_popover: *gtk.Popover,
|
||||
/// they belong to are on screen.
|
||||
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
|
||||
|
||||
/// What a sidebar row is signalling. Defined with the dots themselves, since
|
||||
/// What a sidebar row is signaling. Defined with the dots themselves, since
|
||||
/// a row and a pane header show the same five states for the same reasons.
|
||||
const Attention = Pane.Attention;
|
||||
|
||||
@@ -155,17 +156,17 @@ const Source = struct {
|
||||
///
|
||||
/// Shaped like the pane drag in `View`, and for the same reason: the reorder is
|
||||
/// applied as the pointer moves rather than on the drop, so the sidebar under
|
||||
/// the cursor is always the order you will get. That means a cancelled drag has
|
||||
/// the cursor is always the order you will get. That means a canceled drag has
|
||||
/// something to undo, which is what `origin` is for.
|
||||
const Drag = struct {
|
||||
tab: *Tab,
|
||||
|
||||
/// Where the tab sat in `tabs` when the drag began, so a cancelled drag can
|
||||
/// Where the tab sat in `tabs` when the drag began, so a canceled drag can
|
||||
/// put it back.
|
||||
origin: usize,
|
||||
|
||||
/// Set once a drop has been accepted; a drag that ends without this was
|
||||
/// cancelled, and the preview has to be undone.
|
||||
/// canceled, and the preview has to be undone.
|
||||
committed: bool = false,
|
||||
};
|
||||
|
||||
@@ -177,7 +178,7 @@ const Tab = struct {
|
||||
row: *gtk.ListBoxRow,
|
||||
label: *gtk.Label,
|
||||
|
||||
/// Shows what the tab's focused pane is, so a web view is recognisable in
|
||||
/// Shows what the tab's focused pane is, so a web view is recognizable in
|
||||
/// the sidebar without reading the title.
|
||||
icon: *gtk.Image,
|
||||
|
||||
@@ -228,6 +229,13 @@ const Tab = struct {
|
||||
/// with me?", which only you can answer.
|
||||
finished_since_visit: bool = false,
|
||||
|
||||
/// Whether this tab's finishes are posting notifications, and until when.
|
||||
///
|
||||
/// Session state rather than a setting: a mute is set from the row menu to
|
||||
/// get through the next hour, and a tab does not survive the app anyway, so
|
||||
/// there is nothing here worth writing to a file. See `notify.Mute`.
|
||||
mute: notify.Mute = .off,
|
||||
|
||||
name: [16]u8,
|
||||
name_len: usize,
|
||||
|
||||
@@ -334,7 +342,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
||||
|
||||
// One drop target for the whole list rather than one per row, so that the
|
||||
// gaps between rows and the empty space under the last one are part of it:
|
||||
// a drag to the bottom of the sidebar should land there, not be cancelled
|
||||
// a drag to the bottom of the sidebar should land there, not be canceled
|
||||
// for having missed every row by a few pixels.
|
||||
self.installRowDropTarget();
|
||||
|
||||
@@ -605,18 +613,15 @@ fn bindReview(self: *Window, tab: *Tab) void {
|
||||
//
|
||||
// It does not select the row it belongs to. Renaming a tab, or giving it an
|
||||
// emoji, is not a reason to go and look at it — often it is the opposite, since
|
||||
// the tab you are labelling is the one you are about to leave alone for a while.
|
||||
// the tab you are labeling is the one you are about to leave alone for a while.
|
||||
|
||||
/// Attach the row's context menu and the right-click that opens it.
|
||||
///
|
||||
/// The menu's contents are not built here — see `fillRowMenu`, which builds them
|
||||
/// as it opens.
|
||||
fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void {
|
||||
_ = self;
|
||||
|
||||
const box = gtk.Box.new(.vertical, 2);
|
||||
box.as(gtk.Widget).addCssClass("playpen-row-menu");
|
||||
box.append(menuItem("Rename", &onMenuRename, tab));
|
||||
box.append(menuItem("Settings…", &onMenuSettings, tab));
|
||||
|
||||
tab.menu_popover.setChild(box.as(gtk.Widget));
|
||||
tab.menu_popover.setHasArrow(0);
|
||||
tab.menu_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget));
|
||||
|
||||
@@ -632,6 +637,53 @@ fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void {
|
||||
anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController));
|
||||
}
|
||||
|
||||
/// Build the menu's contents, immediately before it opens.
|
||||
///
|
||||
/// Rebuilt per opening rather than built once and switched about, because half
|
||||
/// of what it says is a deadline that has been quietly passing while the popover
|
||||
/// sat there unopened. There is no moment other than "now" at which "muted for
|
||||
/// another forty minutes" can be made true.
|
||||
fn fillRowMenu(tab: *Tab) void {
|
||||
const box = gtk.Box.new(.vertical, 2);
|
||||
box.as(gtk.Widget).addCssClass("playpen-row-menu");
|
||||
box.append(menuItem("Rename", &onMenuRename, tab));
|
||||
box.append(menuItem("Settings…", &onMenuSettings, tab));
|
||||
|
||||
box.append(gtk.Separator.new(.horizontal).as(gtk.Widget));
|
||||
|
||||
// Cleared as the menu opens as well as when something wants to post, so a
|
||||
// mute that ran out an hour ago isn't still described as one.
|
||||
const now = notify.nowMs();
|
||||
if (tab.mute.expired(now)) tab.mute = .off;
|
||||
|
||||
var buf: [80]u8 = undefined;
|
||||
if (tab.mute.describe(now, &buf)) |state| {
|
||||
// A label rather than an insensitive menu item: this is the answer to
|
||||
// "why has this tab gone quiet", and there is nothing to click.
|
||||
const label = gtk.Label.new(state);
|
||||
label.setXalign(0);
|
||||
label.as(gtk.Widget).addCssClass("playpen-row-menu-state");
|
||||
box.append(label.as(gtk.Widget));
|
||||
|
||||
box.append(menuItem("Unmute", &onMenuUnmute, tab));
|
||||
}
|
||||
|
||||
// Offered whether or not the tab is already muted: re-picking is how you
|
||||
// change your mind about how long, and having to unmute first to mute again
|
||||
// for longer would be a menu arguing with you.
|
||||
inline for (std.enums.values(notify.Duration)) |duration| {
|
||||
const Item = struct {
|
||||
fn clicked(_: *gtk.Button, clicked_tab: *Tab) callconv(.c) void {
|
||||
clicked_tab.menu_popover.popdown();
|
||||
clicked_tab.mute = duration.mute(notify.nowMs());
|
||||
}
|
||||
};
|
||||
box.append(menuItem(duration.label(), &Item.clicked, tab));
|
||||
}
|
||||
|
||||
tab.menu_popover.setChild(box.as(gtk.Widget));
|
||||
}
|
||||
|
||||
/// One line of the row menu, styled like the layout menu's rows so the two
|
||||
/// popovers read as the same kind of thing.
|
||||
fn menuItem(
|
||||
@@ -647,7 +699,7 @@ fn menuItem(
|
||||
return button.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Open the menu where the pointer is, rather than centred on the row: with one
|
||||
/// Open the menu where the pointer is, rather than centered on the row: with one
|
||||
/// popover per row anchored to the whole row, a fixed position would put the
|
||||
/// menu somewhere you weren't pointing.
|
||||
fn onRowSecondary(
|
||||
@@ -663,6 +715,7 @@ fn onRowSecondary(
|
||||
.f_width = 1,
|
||||
.f_height = 1,
|
||||
};
|
||||
fillRowMenu(tab);
|
||||
tab.menu_popover.setPointingTo(&at);
|
||||
tab.menu_popover.popup();
|
||||
}
|
||||
@@ -677,6 +730,11 @@ fn onMenuSettings(_: *gtk.Button, tab: *Tab) callconv(.c) void {
|
||||
tab.window.openTabSettings(tab);
|
||||
}
|
||||
|
||||
fn onMenuUnmute(_: *gtk.Button, tab: *Tab) callconv(.c) void {
|
||||
tab.menu_popover.popdown();
|
||||
tab.mute = .off;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-tab settings
|
||||
|
||||
@@ -1290,12 +1348,16 @@ fn select(self: *Window, tab: *Tab) void {
|
||||
self.list.selectRow(tab.row);
|
||||
tab.view.focus();
|
||||
|
||||
// Opening the tab is the acknowledgement the row was asking for, and the
|
||||
// Opening the tab is the acknowledgment the row was asking for, and the
|
||||
// pane you land in counts as answered along with it. Any other pane in a
|
||||
// split keeps its own dot until you go to it.
|
||||
tab.finished_since_visit = false;
|
||||
tab.view.answerFocused();
|
||||
self.refreshStatus(tab);
|
||||
|
||||
// And it answers the popup too, which would otherwise sit in the tray
|
||||
// telling you about a tab you are now looking at.
|
||||
if (self.application()) |app| notify.withdraw(app, tab.pageName());
|
||||
}
|
||||
|
||||
fn indexOf(self: *Window, tab: *Tab) ?usize {
|
||||
@@ -1416,7 +1478,7 @@ fn onRowDragPrepare(
|
||||
|
||||
// A lone tab has nothing to be reordered against, so there is no preview to
|
||||
// show and no drop that could change anything. Refusing the drag is better
|
||||
// than starting one that can only ever be cancelled.
|
||||
// than starting one that can only ever be canceled.
|
||||
if (self.tabs.items.len < 2) return null;
|
||||
|
||||
self.drag = .{ .tab = tab, .origin = self.indexOf(tab) orelse return null };
|
||||
@@ -1694,7 +1756,7 @@ fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
|
||||
self.openSettings();
|
||||
}
|
||||
|
||||
/// The colour scheme changed. Everything styled by CSS restyles itself; the
|
||||
/// The color scheme changed. Everything styled by CSS restyles itself; the
|
||||
/// terminal grids do not, because Cairo draws them from `theme.zig` and GTK
|
||||
/// has no idea that widget's contents depend on the palette at all. Without
|
||||
/// this, switching to light leaves every terminal a dark rectangle until
|
||||
@@ -1702,7 +1764,7 @@ fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
|
||||
///
|
||||
/// Sessions already open are re-palletted rather than left on the one they
|
||||
/// started in: a shell you have had running all day is exactly the one you are
|
||||
/// looking at when you switch, and leaving it in the old scheme's colours
|
||||
/// looking at when you switch, and leaving it in the old scheme's colors
|
||||
/// would make the setting look like it only applies to new tabs.
|
||||
fn onAppearanceChanged(ctx: ?*anyopaque) void {
|
||||
const self: *Window = @ptrCast(@alignCast(ctx.?));
|
||||
@@ -1810,10 +1872,54 @@ fn onViewStatus(ctx: ?*anyopaque) void {
|
||||
/// Recorded even when the tab is the one on screen: you may well have watched
|
||||
/// it stop and then gone somewhere else, and the next time you open this tab is
|
||||
/// when that stops being news.
|
||||
fn onViewFinished(ctx: ?*anyopaque) void {
|
||||
fn onViewFinished(ctx: ?*anyopaque, task: []const u8) void {
|
||||
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
|
||||
tab.finished_since_visit = true;
|
||||
|
||||
// The row first, always, and the popup second, maybe: the dot is the half
|
||||
// that is never wrong and never unwanted.
|
||||
tab.window.refreshStatus(tab);
|
||||
tab.window.notifyFinished(tab, task);
|
||||
}
|
||||
|
||||
/// Tell the desktop that a pane finished, unless something says not to.
|
||||
///
|
||||
/// See `notify.wanted` for the three things that can say not to.
|
||||
fn notifyFinished(self: *Window, tab: *Tab, task: []const u8) void {
|
||||
// A timed mute is cleared here rather than on a timer. Nothing needs to
|
||||
// know it has run out until something wants to post, and a GLib timeout per
|
||||
// muted tab would be a great deal of machinery for a deadline nobody is
|
||||
// watching.
|
||||
const now = notify.nowMs();
|
||||
if (tab.mute.expired(now)) tab.mute = .off;
|
||||
|
||||
// "You watched it happen" is the window having the focus and this being the
|
||||
// tab it is showing. Everything the decision rests on is gathered here and
|
||||
// weighed in `notify.wanted`, which is where it can be tested.
|
||||
const watching = self.window.as(gtk.Window).isActive() != 0 and self.activeTab() == tab;
|
||||
|
||||
if (!notify.wanted(.{
|
||||
.enabled = Settings.get().notifications,
|
||||
.mute = tab.mute,
|
||||
.watching = watching,
|
||||
}, now)) return;
|
||||
|
||||
const app = self.application() orelse return;
|
||||
|
||||
var buf: [192]u8 = undefined;
|
||||
notify.post(app, tab.pageName(), self.tabName(tab, &buf), task);
|
||||
}
|
||||
|
||||
/// The `GApplication` this window belongs to, which is what carries a
|
||||
/// notification to the session.
|
||||
///
|
||||
/// Fetched each time rather than held. It is the same object for the life of the
|
||||
/// process, but a window part-way through teardown has already been unparented
|
||||
/// from it, and a session exiting during teardown is exactly when a finish can
|
||||
/// still arrive.
|
||||
fn application(self: *Window) ?*gio.Application {
|
||||
const app = self.window.as(gtk.Window).getApplication() orelse return null;
|
||||
return app.as(gio.Application);
|
||||
}
|
||||
|
||||
/// The view lost its last pane, so the tab goes with it.
|
||||
@@ -2145,7 +2251,7 @@ fn addReviewPane(self: *Window, tab: *Tab) void {
|
||||
/// repositories reviews the one you are looking at.
|
||||
///
|
||||
/// Falls back to playpen's own working directory, which at least gives the
|
||||
/// server something to fail on that the user can recognise in the message.
|
||||
/// server something to fail on that the user can recognize in the message.
|
||||
fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
|
||||
_ = self;
|
||||
|
||||
@@ -2160,7 +2266,7 @@ fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
|
||||
}
|
||||
|
||||
// Playpen's own directory, which at least gives the server something to
|
||||
// fail on that the user can recognise in the message. `std.c` rather than
|
||||
// fail on that the user can recognize in the message. `std.c` rather than
|
||||
// `std.posix`, matching `Pty.zig`: the latter has been churning across Zig
|
||||
// releases and this is one call.
|
||||
if (std.c.getcwd(buf.ptr, buf.len) != null) {
|
||||
|
||||
+10
-10
@@ -1,14 +1,14 @@
|
||||
//! Which palette the app is painted with, and the plumbing that keeps three
|
||||
//! separate colour systems agreeing about it.
|
||||
//! separate color systems agreeing about it.
|
||||
//!
|
||||
//! * **libadwaita's style manager** colours every stock widget — popovers,
|
||||
//! * **libadwaita's style manager** colors every stock widget — popovers,
|
||||
//! entries, scrollbars, dialog chrome. It is told to force light or dark, or
|
||||
//! left on `default` so it follows the desktop.
|
||||
//! * **`style.css`** colours everything the app draws for itself. It is
|
||||
//! written entirely against named colours; `palette.zig` supplies their
|
||||
//! * **`style.css`** colors everything the app draws for itself. It is
|
||||
//! written entirely against named colors; `palette.zig` supplies their
|
||||
//! values for the scheme in force, and the two are loaded as a single
|
||||
//! provider.
|
||||
//! * **`theme.zig`** holds the colours the terminal renderer reads. Cairo never
|
||||
//! * **`theme.zig`** holds the colors the terminal renderer reads. Cairo never
|
||||
//! consults the style tree, so a CSS reload on its own would leave every
|
||||
//! terminal grid painted in the palette it started with.
|
||||
//!
|
||||
@@ -19,7 +19,7 @@
|
||||
//! that property means a desktop that changes scheme at sunset repaints this
|
||||
//! app too, with no extra machinery and no second source of truth.
|
||||
//!
|
||||
//! Editing a colour comes in through `refresh`, which is the same repaint
|
||||
//! Editing a color comes in through `refresh`, which is the same repaint
|
||||
//! without the trip through libadwaita: the scheme hasn't changed, only what it
|
||||
//! is painted in.
|
||||
|
||||
@@ -33,14 +33,14 @@ const Settings = @import("Settings.zig");
|
||||
const palette = @import("palette.zig");
|
||||
const theme = @import("theme.zig");
|
||||
|
||||
/// The stylesheet, which is the same in both schemes: every colour in it is a
|
||||
/// The stylesheet, which is the same in both schemes: every color in it is a
|
||||
/// name, and the names are defined by the block `palette.zig` writes ahead of
|
||||
/// it.
|
||||
const style = @embedFile("style.css");
|
||||
|
||||
/// The two are handed to GTK as one string, so they live in one buffer. Static
|
||||
/// rather than allocated: this is written on every scheme change and on every
|
||||
/// colour edited, and a repaint that can fail for want of memory would be a
|
||||
/// color edited, and a repaint that can fail for want of memory would be a
|
||||
/// stylesheet that half applies.
|
||||
var css_buf: [palette.css_size + style.len + 1]u8 = undefined;
|
||||
|
||||
@@ -60,7 +60,7 @@ fn buildCss(scheme: palette.Scheme) [:0]const u8 {
|
||||
}
|
||||
|
||||
/// Called after the scheme changes, so the owner can repaint the things GTK
|
||||
/// doesn't know are colour-dependent — the Cairo-drawn terminal grids.
|
||||
/// doesn't know are color-dependent — the Cairo-drawn terminal grids.
|
||||
pub const Callback = *const fn (ctx: ?*anyopaque) void;
|
||||
|
||||
var provider: ?*gtk.CssProvider = null;
|
||||
@@ -141,7 +141,7 @@ fn onDarkChanged(
|
||||
|
||||
/// Repaint in the palette as it now stands, without touching the preference.
|
||||
///
|
||||
/// This is what the colour editor calls after changing a colour: the scheme is
|
||||
/// This is what the color editor calls after changing a color: the scheme is
|
||||
/// whatever it already was, and everything painted from it — the stylesheet, the
|
||||
/// terminal grids — is rebuilt from the new value.
|
||||
pub fn refresh() void {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
//! Unicode lists nearly twice this many fully-qualified sequences and the
|
||||
//! difference is almost entirely the same gesture in five tones, which makes a
|
||||
//! grid longer to look through without making it more complete. The version
|
||||
//! cutoff is there because colour emoji fonts trail Unicode, and a glyph the
|
||||
//! cutoff is there because color emoji fonts trail Unicode, and a glyph the
|
||||
//! font has never heard of draws as a hex-digit box; the generator's header
|
||||
//! explains how to raise it.
|
||||
//!
|
||||
|
||||
+336
@@ -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
@@ -1,6 +1,6 @@
|
||||
//! sRGB in, sRGB out, with a perceptual space in the middle.
|
||||
//!
|
||||
//! This exists for `tint.zig`, which builds a whole palette out of one colour
|
||||
//! This exists for `tint.zig`, which builds a whole palette out of one color
|
||||
//! and therefore has to answer questions like "the same hue, a third as bright"
|
||||
//! and "one step lighter than that". Those questions have no good answer in RGB
|
||||
//! — halving the channels of a mid blue gives a navy, halving the channels of a
|
||||
@@ -14,10 +14,10 @@
|
||||
//! polar form — lightness, chroma, hue — is what a palette is really made of:
|
||||
//! hold the hue, walk the lightness, and the surfaces of a theme fall out.
|
||||
//!
|
||||
//! The one thing OKLCh will happily do that sRGB will not is name a colour that
|
||||
//! The one thing OKLCh will happily do that sRGB will not is name a color that
|
||||
//! does not exist on a monitor — a fully saturated yellow at the lightness of a
|
||||
//! midtone, say. `toRgb` deals with that by giving up chroma rather than
|
||||
//! lightness or hue: the result is the most colourful version of the colour that
|
||||
//! lightness or hue: the result is the most colorful version of the color that
|
||||
//! can actually be shown, which is what "as close as the screen gets" should
|
||||
//! mean for a palette. Clipping the channels instead would shift the hue, and a
|
||||
//! generated theme whose reds drift orange as they darken looks broken in a way
|
||||
@@ -31,13 +31,13 @@ const palette = @import("palette.zig");
|
||||
|
||||
const Rgb = palette.Rgb;
|
||||
|
||||
/// A colour in OKLCh: how light, how colourful, and which colour.
|
||||
/// A color in OKLCh: how light, how colorful, and which color.
|
||||
pub const Lch = struct {
|
||||
/// Perceptual lightness. 0 is black, 1 is white, and 0.5 looks like a
|
||||
/// midtone rather than merely being one arithmetically.
|
||||
l: f32,
|
||||
|
||||
/// Colourfulness. 0 is a grey; how high it can go before leaving sRGB
|
||||
/// Colorfulness. 0 is a gray; how high it can go before leaving sRGB
|
||||
/// depends on both the lightness and the hue, and is around 0.32 at best.
|
||||
c: f32,
|
||||
|
||||
@@ -59,17 +59,17 @@ pub fn fromRgb(rgb: Rgb) Lch {
|
||||
return .{
|
||||
.l = lab.l,
|
||||
.c = c,
|
||||
// A grey has no hue to report, and `atan2` on two zeroes is entitled to
|
||||
// A gray has no hue to report, and `atan2` on two zeroes is entitled to
|
||||
// say anything. Zero is as good as any other answer and is at least the
|
||||
// same one every time, which matters: the palette generator reads a hue
|
||||
// off the base colour and gives it to forty other colours, and a grey
|
||||
// off the base color and gives it to forty other colors, and a gray
|
||||
// base that produced a different hue on each launch would be a theme
|
||||
// that changed colour when you restarted the app.
|
||||
// that changed color when you restarted the app.
|
||||
.h = if (c < 1e-6) 0 else std.math.radiansToDegrees(std.math.atan2(lab.b, lab.a)),
|
||||
};
|
||||
}
|
||||
|
||||
/// The nearest colour a screen can show, giving up chroma before anything else.
|
||||
/// The nearest color a screen can show, giving up chroma before anything else.
|
||||
pub fn toRgb(lch: Lch) Rgb {
|
||||
const l = std.math.clamp(lch.l, 0, 1);
|
||||
const rad = std.math.degreesToRadians(lch.h);
|
||||
@@ -90,7 +90,7 @@ pub fn toRgb(lch: Lch) Rgb {
|
||||
/// is not one: it is the image of a cube through a cube root, and the closed
|
||||
/// forms for it are approximations with their own error. Twelve halvings of a
|
||||
/// range that is at most 1.0 wide lands well inside a 1/255 step, and this runs
|
||||
/// forty times when someone drags a colour picker.
|
||||
/// forty times when someone drags a color picker.
|
||||
fn fit(l: f32, c: f32, rad: f32) f32 {
|
||||
if (Lab.at(l, c, rad).inGamut()) return c;
|
||||
|
||||
@@ -133,9 +133,9 @@ const Lab = struct {
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether this colour is one a screen can show. The tolerance is a hair
|
||||
/// Whether this color is one a screen can show. The tolerance is a hair
|
||||
/// over half of an 8-bit step in linear light near the top of the range, so
|
||||
/// a colour that is out of gamut only by the arithmetic isn't hunted down
|
||||
/// a color that is out of gamut only by the arithmetic isn't hunted down
|
||||
/// by the bisection above for no visible gain.
|
||||
fn inGamut(self: Lab) bool {
|
||||
const lin = self.toLinear();
|
||||
@@ -188,8 +188,8 @@ fn encode(channel: f32) u8 {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test "a colour survives the round trip" {
|
||||
// Every hue family, plus the two ends and a grey, since those are the cases
|
||||
test "a color survives the round trip" {
|
||||
// Every hue family, plus the two ends and a gray, since those are the cases
|
||||
// where the hue is undefined and the transfer function is at its steepest.
|
||||
const cases = [_][]const u8{
|
||||
"#000000", "#ffffff", "#808080", "#3a76f0", "#d2691e",
|
||||
@@ -220,14 +220,14 @@ test "black and white are where they should be" {
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 0), fromRgb(.{ .r = 0, .g = 0, .b = 0 }).l, 1e-4);
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 1), fromRgb(.{ .r = 255, .g = 255, .b = 255 }).l, 1e-4);
|
||||
|
||||
// A grey has no hue, and says so rather than saying whatever `atan2` makes
|
||||
// A gray has no hue, and says so rather than saying whatever `atan2` makes
|
||||
// of two zeroes.
|
||||
const grey = fromRgb(.{ .r = 128, .g = 128, .b = 128 });
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 0), grey.c, 1e-3);
|
||||
try std.testing.expectEqual(@as(f32, 0), grey.h);
|
||||
const gray = fromRgb(.{ .r = 128, .g = 128, .b = 128 });
|
||||
try std.testing.expectApproxEqAbs(@as(f32, 0), gray.c, 1e-3);
|
||||
try std.testing.expectEqual(@as(f32, 0), gray.h);
|
||||
}
|
||||
|
||||
test "an impossible colour gives up chroma, not hue" {
|
||||
test "an impossible color gives up chroma, not hue" {
|
||||
// A fully saturated yellow at the lightness of a midtone: nothing like it
|
||||
// exists in sRGB, and asking for it has to produce *something*.
|
||||
const asked: Lch = .{ .l = 0.5, .c = 0.3, .h = 100 };
|
||||
@@ -237,7 +237,7 @@ test "an impossible colour gives up chroma, not hue" {
|
||||
try std.testing.expectApproxEqAbs(asked.h, got.h, 1.5);
|
||||
try std.testing.expect(got.c < asked.c);
|
||||
|
||||
// And it is still as colourful as sRGB allows, rather than having been
|
||||
// And it is still as colorful as sRGB allows, rather than having been
|
||||
// rounded down to something safe: pushing it back up leaves the gamut.
|
||||
try std.testing.expect(!Lab.at(asked.l, got.c + 0.01, std.math.degreesToRadians(asked.h)).inGamut());
|
||||
}
|
||||
|
||||
+53
-53
@@ -1,9 +1,9 @@
|
||||
//! The palette: every colour the app paints itself with, what each one defaults
|
||||
//! The palette: every color the app paints itself with, what each one defaults
|
||||
//! to in either scheme, and the shape an override takes when someone changes it.
|
||||
//!
|
||||
//! This used to be two stylesheets — `palette-dark.css` and
|
||||
//! `palette-light.css`, one of them prepended to `style.css` at compile time —
|
||||
//! and that was the right shape right up until the settings page grew a colour
|
||||
//! and that was the right shape right up until the settings page grew a color
|
||||
//! editor. An editor needs the *names*, to list them; the *defaults*, to say
|
||||
//! which ones have been changed and to put them back; and some *grouping*, so
|
||||
//! that the list reads as a palette rather than as forty hex fields. None of
|
||||
@@ -12,8 +12,8 @@
|
||||
//!
|
||||
//! So the CSS is generated from here instead: `writeCss` emits one
|
||||
//! `@define-color` per name, `appearance.zig` prepends the result to
|
||||
//! `style.css`, and `theme.zig` resolves the terminal renderer's colours out of
|
||||
//! the same table. There is still exactly one place a colour is written down.
|
||||
//! `style.css`, and `theme.zig` resolves the terminal renderer's colors out of
|
||||
//! the same table. There is still exactly one place a color is written down.
|
||||
//!
|
||||
//! Overrides are held per scheme and only where they exist. That is what makes
|
||||
//! "reset" and "you have changed this one" answerable at all, and it means a
|
||||
@@ -21,10 +21,10 @@
|
||||
//! touched it, rather than only the people who had never opened the editor.
|
||||
//!
|
||||
//! Under the overrides there is one more layer: a `Tint`, which is a single
|
||||
//! colour that `tint.zig` builds an entire palette out of. It stands in for the
|
||||
//! color that `tint.zig` builds an entire palette out of. It stands in for the
|
||||
//! defaults rather than beside them, so the three layers read outward from the
|
||||
//! most specific — this colour, then the colour built from your base, then the
|
||||
//! colour Playpen ships. That ordering is what lets someone pick a base and then
|
||||
//! most specific — this color, then the color built from your base, then the
|
||||
//! color Playpen ships. That ordering is what lets someone pick a base and then
|
||||
//! fix the two swatches they didn't like without the fix being undone the next
|
||||
//! time they nudge the base, and it is why `Changes` is a struct rather than the
|
||||
//! bare override array it used to be.
|
||||
@@ -89,7 +89,7 @@ pub const Rgb = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// A resolved colour scheme. Not the same thing as the user's preference, which
|
||||
/// A resolved color scheme. Not the same thing as the user's preference, which
|
||||
/// has a third option — see `Settings.Theme`. By the time a scheme reaches this
|
||||
/// module, "system" has been resolved to one of these.
|
||||
pub const Scheme = enum {
|
||||
@@ -116,7 +116,7 @@ pub const Group = enum {
|
||||
terminal,
|
||||
ansi,
|
||||
|
||||
/// Whether this group's colours reach the UI as named CSS colours. The two
|
||||
/// Whether this group's colors reach the UI as named CSS colors. The two
|
||||
/// that don't are the terminal's: Cairo draws the grid itself and never
|
||||
/// consults the style tree.
|
||||
pub fn isCss(self: Group) bool {
|
||||
@@ -134,7 +134,7 @@ pub const Group = enum {
|
||||
.accents => "Accent",
|
||||
.states => "Status",
|
||||
.terminal => "Terminal",
|
||||
.ansi => "Terminal ANSI colours",
|
||||
.ansi => "Terminal ANSI colors",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,17 +143,17 @@ pub const Group = enum {
|
||||
return switch (self) {
|
||||
.surfaces => "Darkest first: the window backdrop shows through as the gutter between panes.",
|
||||
.terminal => "What a program in the terminal gets before it asks for anything else. " ++
|
||||
"Left alone, these follow the surface, text and accent colours above.",
|
||||
.ansi => "The 16 colours programs ask for by name. " ++
|
||||
"Left alone, these follow the surface, text and accent colors above.",
|
||||
.ansi => "The 16 colors programs ask for by name. " ++
|
||||
"The 240 above them are fixed by spec and not editable.",
|
||||
.text, .rows, .accents, .states => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Every colour in the palette.
|
||||
/// Every color in the palette.
|
||||
///
|
||||
/// The tag is the wire format — it is what a colour is called in
|
||||
/// The tag is the wire format — it is what a color is called in
|
||||
/// `settings.json` — and for everything in a CSS group it is also the name in
|
||||
/// the stylesheet, with `pp_` in front. Renaming one is therefore a migration,
|
||||
/// which is why the names here are the ones `style.css` already used.
|
||||
@@ -195,7 +195,7 @@ pub const Key = enum {
|
||||
term_selection_bg,
|
||||
term_selection_fg,
|
||||
|
||||
// ---- the 16 named ANSI colours, in palette order ----
|
||||
// ---- the 16 named ANSI colors, in palette order ----
|
||||
ansi_black,
|
||||
ansi_red,
|
||||
ansi_green,
|
||||
@@ -232,7 +232,7 @@ pub const Key = enum {
|
||||
};
|
||||
}
|
||||
|
||||
/// The name `style.css` knows this colour by, for the groups that reach CSS
|
||||
/// The name `style.css` knows this color by, for the groups that reach CSS
|
||||
/// at all. Derived from the tag rather than written out again, so the two
|
||||
/// cannot drift.
|
||||
pub fn cssName(self: Key) ?[:0]const u8 {
|
||||
@@ -244,16 +244,16 @@ pub const Key = enum {
|
||||
};
|
||||
}
|
||||
|
||||
/// This colour's index in the 256-colour palette, for the 16 that have one.
|
||||
/// This color's index in the 256-color palette, for the 16 that have one.
|
||||
pub fn ansiIndex(self: Key) ?u8 {
|
||||
if (self.group() != .ansi) return null;
|
||||
return @intCast(@intFromEnum(self) - @intFromEnum(Key.ansi_black));
|
||||
}
|
||||
|
||||
/// The colour this one falls back to when it hasn't been set.
|
||||
/// The color this one falls back to when it hasn't been set.
|
||||
///
|
||||
/// This is what keeps the terminal and the frame around it looking like one
|
||||
/// surface without asking anyone to set the same colour twice. The
|
||||
/// surface without asking anyone to set the same color twice. The
|
||||
/// terminal's background *is* the pane surface, its foreground *is* the
|
||||
/// body text, and a selection *is* the muted accent — until someone says
|
||||
/// otherwise, at which point they come apart, which is the whole reason
|
||||
@@ -318,7 +318,7 @@ pub const Key = enum {
|
||||
};
|
||||
}
|
||||
|
||||
/// What this colour is for, for the ones where the name isn't the whole
|
||||
/// What this color is for, for the ones where the name isn't the whole
|
||||
/// story. Shown as the row's tooltip.
|
||||
pub fn hint(self: Key) ?[:0]const u8 {
|
||||
return switch (self) {
|
||||
@@ -326,7 +326,7 @@ pub const Key = enum {
|
||||
.surface_raised => "The strip above a pane holding its title",
|
||||
.border_strong => "Edges that have to carry on their own, without a fill behind them",
|
||||
.text_dim => "Secondary labels — hints, counts, addresses",
|
||||
.text_faint => "Things that are labelled rather than read, like the title of a pane you aren't in",
|
||||
.text_faint => "Things that are labeled rather than read, like the title of a pane you aren't in",
|
||||
.accent_strong => "Small marks that have to stay legible on a surface: icons, dots, a row's state bar",
|
||||
.accent_muted => "Washes and the border of the focused pane",
|
||||
.ok => "A pane that finished and hasn't been looked at",
|
||||
@@ -338,7 +338,7 @@ pub const Key = enum {
|
||||
};
|
||||
}
|
||||
|
||||
/// The colour before any override: this key's own default, or the default of
|
||||
/// The color before any override: this key's own default, or the default of
|
||||
/// whatever it inherits from.
|
||||
pub fn default(self: Key, scheme: Scheme) Rgb {
|
||||
if (self.ansiIndex()) |i| return switch (scheme) {
|
||||
@@ -360,17 +360,17 @@ pub const Key = enum {
|
||||
|
||||
pub const count = std.enums.values(Key).len;
|
||||
|
||||
/// The colours someone has changed, per key, in one scheme. A fixed array
|
||||
/// The colors someone has changed, per key, in one scheme. A fixed array
|
||||
/// rather than a list of pairs: it is 120 bytes, it needs no allocator, and it
|
||||
/// makes "is this key overridden" a lookup rather than a search.
|
||||
pub const Overrides = std.enums.EnumArray(Key, ?Rgb);
|
||||
|
||||
pub const no_overrides: Overrides = .initFill(null);
|
||||
|
||||
/// One colour, and how far apart to spread everything built around it. See
|
||||
/// One color, and how far apart to spread everything built around it. See
|
||||
/// `tint.zig` for what "built around it" means.
|
||||
pub const Tint = struct {
|
||||
/// The colour the palette is generated from. It is also, near enough, what
|
||||
/// The color the palette is generated from. It is also, near enough, what
|
||||
/// `accent` comes out as — see `tint.derive`.
|
||||
base: Rgb,
|
||||
|
||||
@@ -384,8 +384,8 @@ pub const Tint = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Everything someone has done to one scheme's palette: the base colour it is
|
||||
/// generated from, if any, and the individual colours they have set by hand.
|
||||
/// Everything someone has done to one scheme's palette: the base color it is
|
||||
/// generated from, if any, and the individual colors they have set by hand.
|
||||
///
|
||||
/// Both, and in that order, rather than either — see the note at the top of the
|
||||
/// file. A base is a starting point and the overrides are the corrections to it,
|
||||
@@ -395,7 +395,7 @@ pub const Changes = struct {
|
||||
overrides: Overrides = no_overrides,
|
||||
|
||||
/// Whether anything in this scheme has been changed at all. What the
|
||||
/// editor's "reset everything" button is greyed out by.
|
||||
/// editor's "reset everything" button is grayed out by.
|
||||
pub fn anySet(self: *const Changes) bool {
|
||||
if (self.tint != null) return true;
|
||||
for (std.enums.values(Key)) |key| {
|
||||
@@ -405,20 +405,20 @@ pub const Changes = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// A scheme nobody has touched. Named so that the places that resolve a colour
|
||||
/// A scheme nobody has touched. Named so that the places that resolve a color
|
||||
/// without any settings to hand — the terminal renderer's compile-time initial
|
||||
/// palette, and the tests — have something to point at.
|
||||
pub const unchanged: Changes = .{};
|
||||
|
||||
/// The colour to paint with: the override if there is one, else what the key
|
||||
/// inherits (overrides and all), else the colour a base generates for it, else
|
||||
/// The color to paint with: the override if there is one, else what the key
|
||||
/// inherits (overrides and all), else the color a base generates for it, else
|
||||
/// the default.
|
||||
///
|
||||
/// The inheritance is resolved through the overrides rather than around them,
|
||||
/// which is the point of it — recolour `surface` and the terminal background
|
||||
/// which is the point of it — recolor `surface` and the terminal background
|
||||
/// follows, without anyone having had to set both. It is resolved *before* the
|
||||
/// generated palette for the same reason: a terminal background whose pane
|
||||
/// surface has been overridden should follow the override, not the base colour
|
||||
/// surface has been overridden should follow the override, not the base color
|
||||
/// the override was correcting.
|
||||
pub fn resolve(key: Key, scheme: Scheme, changes: *const Changes) Rgb {
|
||||
if (changes.overrides.get(key)) |color| return color;
|
||||
@@ -427,11 +427,11 @@ pub fn resolve(key: Key, scheme: Scheme, changes: *const Changes) Rgb {
|
||||
return key.default(scheme);
|
||||
}
|
||||
|
||||
/// Emit the palette as named CSS colours, for `style.css` to be written
|
||||
/// Emit the palette as named CSS colors, for `style.css` to be written
|
||||
/// against.
|
||||
///
|
||||
/// `appearance.zig` prepends this to the stylesheet and loads the pair as a
|
||||
/// single provider. Only the CSS groups appear; the terminal's colours go to
|
||||
/// single provider. Only the CSS groups appear; the terminal's colors go to
|
||||
/// Cairo instead, through `theme.zig`.
|
||||
pub fn writeCss(
|
||||
scheme: Scheme,
|
||||
@@ -457,17 +457,17 @@ pub const css_size = count * ("@define-color pp_surface_raised_active #rrggbb;\n
|
||||
//
|
||||
// Two tables, one per scheme, in the same shape and the same order as the two
|
||||
// CSS files they replace. A key that inherits (see `Key.inherits`) is absent
|
||||
// from both rather than repeating the colour it would have copied.
|
||||
// from both rather than repeating the color it would have copied.
|
||||
|
||||
const Table = std.enums.EnumArray(Key, ?Rgb);
|
||||
|
||||
/// The default for a key the table below doesn't mention: no colour of its own,
|
||||
/// meaning it inherits one or is an ANSI colour taken from libghostty-vt. Named
|
||||
/// The default for a key the table below doesn't mention: no color of its own,
|
||||
/// meaning it inherits one or is an ANSI color taken from libghostty-vt. Named
|
||||
/// because it has to be typed as an *inner* null — an outer one would mean "this
|
||||
/// table has no default" and make every key mandatory.
|
||||
const unset: ?Rgb = null;
|
||||
|
||||
/// Deep navy rather than neutral grey, lifted a little at each step so the
|
||||
/// Deep navy rather than neutral gray, lifted a little at each step so the
|
||||
/// three surfaces — window, sidebar, pane — separate without any of them
|
||||
/// reading as light.
|
||||
const dark: Table = .initDefault(unset, .{
|
||||
@@ -495,12 +495,12 @@ const dark: Table = .initDefault(unset, .{
|
||||
|
||||
// The three states that are news. Kept away from the accent on purpose: the
|
||||
// accent means "here" and these mean "something happened", and a sidebar
|
||||
// where those are the same colour answers neither question.
|
||||
// where those are the same color answers neither question.
|
||||
.ok = h("#3ecf8e"),
|
||||
.warn = h("#f0b849"),
|
||||
.err = h("#f2717b"),
|
||||
|
||||
// The cursor is the one terminal colour that is nobody else's: it goes to
|
||||
// The cursor is the one terminal color that is nobody else's: it goes to
|
||||
// the bright accent here and *down* to the mid accent on white, because on
|
||||
// a near-white background a bright block swallows the character under it.
|
||||
.term_cursor = h("#6191f3"),
|
||||
@@ -509,7 +509,7 @@ const dark: Table = .initDefault(unset, .{
|
||||
/// Not an inversion of the dark palette. `accent_strong` is *darker* than
|
||||
/// `accent` here, for the same reason it is brighter there — its job is to
|
||||
/// carry as a small mark against the surface it sits on. The three state
|
||||
/// colours are pulled well away from their dark values too: a dot in `#3ecf8e`
|
||||
/// colors are pulled well away from their dark values too: a dot in `#3ecf8e`
|
||||
/// reads clearly on navy and disappears on white.
|
||||
///
|
||||
/// The text stops short of black. Full-contrast black on white is harsher to
|
||||
@@ -541,7 +541,7 @@ const light: Table = .initDefault(unset, .{
|
||||
.term_cursor = h("#2c6bed"),
|
||||
});
|
||||
|
||||
/// The 16 named colours, retuned for a light background.
|
||||
/// The 16 named colors, retuned for a light background.
|
||||
///
|
||||
/// This is the part of a light scheme that is easy to skip and shouldn't be:
|
||||
/// libghostty-vt's default yellow is a mid tan and the standard xterm one is
|
||||
@@ -549,7 +549,7 @@ const light: Table = .initDefault(unset, .{
|
||||
///
|
||||
/// Two entries change meaning rather than brightness. `white` (7) and `bright
|
||||
/// white` (15) are the foreground half of "white text", and on a white
|
||||
/// background they have to go dark or the text they colour disappears
|
||||
/// background they have to go dark or the text they color disappears
|
||||
/// altogether. Every light terminal theme makes this trade: a program that
|
||||
/// asked for a white *background* gets a dark block instead, which is jarring
|
||||
/// but rare, and the alternative is text that cannot be read at all, which is
|
||||
@@ -574,18 +574,18 @@ const light_ansi = [16]Rgb{
|
||||
};
|
||||
|
||||
/// A hex literal for the tables above, which is where the readability is: a
|
||||
/// palette wants to be read as the colours a designer wrote down, not as sixty
|
||||
/// palette wants to be read as the colors a designer wrote down, not as sixty
|
||||
/// structs of three numbers.
|
||||
inline fn h(comptime text: []const u8) Rgb {
|
||||
comptime {
|
||||
// Sixty of these are parsed at compile time, and the integer parser they
|
||||
// go through is not cheap by the branch counter's reckoning.
|
||||
@setEvalBranchQuota(100_000);
|
||||
return Rgb.parse(text) orelse @compileError("not a colour: " ++ text);
|
||||
return Rgb.parse(text) orelse @compileError("not a color: " ++ text);
|
||||
}
|
||||
}
|
||||
|
||||
// Every key has to end up with a colour, or the palette has a hole in it that
|
||||
// Every key has to end up with a color, or the palette has a hole in it that
|
||||
// nothing will report until something is painted with it.
|
||||
comptime {
|
||||
for (std.enums.values(Key)) |key| {
|
||||
@@ -634,7 +634,7 @@ test "ansi keys map to palette indices in order" {
|
||||
try std.testing.expectEqual(@as(?u8, null), Key.accent.ansiIndex());
|
||||
}
|
||||
|
||||
test "an unset colour resolves to its default" {
|
||||
test "an unset color resolves to its default" {
|
||||
try std.testing.expectEqual(Rgb.parse("#070c15").?, resolve(.bg, .dark, &unchanged));
|
||||
try std.testing.expectEqual(Rgb.parse("#d9e1ee").?, resolve(.bg, .light, &unchanged));
|
||||
}
|
||||
@@ -651,16 +651,16 @@ test "the terminal follows the surface it is drawn on" {
|
||||
changes.overrides.set(.surface, Rgb.parse("#123456").?);
|
||||
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.term_bg, .dark, &changes));
|
||||
|
||||
// ...and not once it has been given a colour of its own.
|
||||
// ...and not once it has been given a color of its own.
|
||||
changes.overrides.set(.term_bg, Rgb.parse("#abcdef").?);
|
||||
try std.testing.expectEqual(Rgb.parse("#abcdef").?, resolve(.term_bg, .dark, &changes));
|
||||
try std.testing.expectEqual(Rgb.parse("#123456").?, resolve(.surface, .dark, &changes));
|
||||
}
|
||||
|
||||
test "a base colour stands in for the defaults, and an override for the base" {
|
||||
test "a base color stands in for the defaults, and an override for the base" {
|
||||
var changes: Changes = .{ .tint = .{ .base = Rgb.parse("#d2691e").? } };
|
||||
|
||||
// Nothing has been set by hand, so every colour is the generated one...
|
||||
// Nothing has been set by hand, so every color is the generated one...
|
||||
try std.testing.expect(!resolve(.bg, .dark, &changes).eql(Key.bg.default(.dark)));
|
||||
try std.testing.expectEqual(
|
||||
Rgb.parse("#d2691e").?,
|
||||
@@ -673,7 +673,7 @@ test "a base colour stands in for the defaults, and an override for the base" {
|
||||
resolve(.term_bg, .dark, &changes),
|
||||
);
|
||||
|
||||
// ...and a colour set by hand outranks the base rather than the other way
|
||||
// ...and a color set by hand outranks the base rather than the other way
|
||||
// round, which is what lets the two be used together.
|
||||
changes.overrides.set(.accent, Rgb.parse("#00ff00").?);
|
||||
try std.testing.expectEqual(Rgb.parse("#00ff00").?, resolve(.accent, .dark, &changes));
|
||||
@@ -691,7 +691,7 @@ test "anySet notices a base with nothing else changed" {
|
||||
try std.testing.expect(edited.anySet());
|
||||
}
|
||||
|
||||
test "writeCss emits every named colour and no others" {
|
||||
test "writeCss emits every named color and no others" {
|
||||
var buf: [css_size]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ fn save(self: *Store) Error!void {
|
||||
/// Deliberately not filtered by diff context. A comment is content someone
|
||||
/// typed: it has to survive the base ref moving, the working tree being
|
||||
/// committed, or the page being reloaded onto a different selection. Whether a
|
||||
/// comment still lines up with the diff on screen is the frontend's judgement —
|
||||
/// comment still lines up with the diff on screen is the frontend's judgment —
|
||||
/// it has the parsed diff, and it marks the ones it cannot place as outdated.
|
||||
pub fn list(self: *Store, gpa: std.mem.Allocator) ![]Comment {
|
||||
self.mutex.lockUncancelable(self.io);
|
||||
|
||||
+50
-33
@@ -1,15 +1,15 @@
|
||||
/* Zen-style vertical tabs: a sidebar column with the terminal inset to its
|
||||
right.
|
||||
|
||||
Every colour here is a name from `palette.zig`, which writes the matching
|
||||
Every color here is a name from `palette.zig`, which writes the matching
|
||||
`@define-color` block for the scheme in force; `appearance.zig` prepends that
|
||||
and loads the pair as a single provider. Nothing below may hardcode a colour —
|
||||
and loads the pair as a single provider. Nothing below may hardcode a color —
|
||||
a literal hex would be a rule that looks right in one scheme and wrong in the
|
||||
other, it would look right in whichever one you happened to be testing in,
|
||||
and the colour editor in the settings page could not reach it at all.
|
||||
and the color editor in the settings page could not reach it at all.
|
||||
|
||||
`alpha()` is used for every wash rather than a second named colour, so a
|
||||
state's tint is always derived from the state's own colour and the two
|
||||
`alpha()` is used for every wash rather than a second named color, so a
|
||||
state's tint is always derived from the state's own color and the two
|
||||
can't drift apart. */
|
||||
|
||||
.playpen-window {
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
The rule underneath is stated explicitly rather than left to the one Adwaita
|
||||
draws for every header bar: that one is a shadow in the desktop theme's
|
||||
colour, which on this palette read as a stray line rather than as the pair
|
||||
color, which on this palette read as a stray line rather than as the pair
|
||||
it now makes with the footer's. */
|
||||
.playpen-sidebar headerbar {
|
||||
background: none;
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
/* The two header actions. Styled explicitly rather than left to Adwaita, whose
|
||||
default button is a grey that belongs to a different window than this one.
|
||||
default button is a gray that belongs to a different window than this one.
|
||||
Scoped by class instead of by `headerbar button` so the window controls
|
||||
beside them keep their own appearance — those belong to the desktop, not to
|
||||
the app, and making them match would be the wrong kind of consistent.
|
||||
@@ -113,9 +113,9 @@ button.playpen-header-button:hover,
|
||||
}
|
||||
|
||||
/* The kind icon picks up the accent on the current row. The status dot is
|
||||
excluded: it carries a colour that *is* the information, and this rule is
|
||||
excluded: it carries a color that *is* the information, and this rule is
|
||||
specific enough to beat the state classes, which used to leave the selected
|
||||
row's dot accent-coloured whatever it was trying to say. */
|
||||
row's dot accent-colored whatever it was trying to say. */
|
||||
.playpen-list > row:selected image:not(.playpen-status-dot) {
|
||||
color: @pp_accent_strong;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ button.playpen-header-button:hover,
|
||||
rather than left to the glyph, so a sidebar mixing emoji rows with icon rows
|
||||
still starts every label in the same column — emoji vary in advance width and
|
||||
without this the labels beside them would step in and out by a pixel or two
|
||||
per row. The font size is a little under the icon's 16px because a colour
|
||||
per row. The font size is a little under the icon's 16px because a color
|
||||
glyph fills its box where a symbolic icon leaves air around itself. */
|
||||
.playpen-tab-emoji {
|
||||
min-width: 16px;
|
||||
@@ -133,7 +133,7 @@ button.playpen-header-button:hover,
|
||||
|
||||
/* State on the row itself, not just on its dot.
|
||||
|
||||
A bar down the leading edge, and behind the row a wash of the same colour for
|
||||
A bar down the leading edge, and behind the row a wash of the same color for
|
||||
the two states that actually want you. The dot says what a row is doing once
|
||||
you are reading it; these are what let you sweep a sidebar of a dozen tabs
|
||||
and land on the one that wants you without reading any of them.
|
||||
@@ -178,7 +178,7 @@ button.playpen-header-button:hover,
|
||||
}
|
||||
|
||||
/* The current row keeps its own background: the wash and the selection would
|
||||
otherwise blend into a colour that reads as neither. The bar survives, which
|
||||
otherwise blend into a color that reads as neither. The bar survives, which
|
||||
is the part that carries the state. Done is absent because it no longer has a
|
||||
wash to suppress. */
|
||||
.playpen-list > row:selected.playpen-attn-input,
|
||||
@@ -201,7 +201,7 @@ button.playpen-header-button:hover,
|
||||
box-shadow: inset 3px 0 0 @pp_accent_strong;
|
||||
}
|
||||
|
||||
/* The copy of that row travelling with the pointer, which GTK draws into a
|
||||
/* The copy of that row traveling with the pointer, which GTK draws into a
|
||||
surface of its own. What it paints is the row's *box* — the icon, the label
|
||||
and the close button — and a row's background belongs to the row above that
|
||||
box, so without this the label would float on nothing. Given the selected
|
||||
@@ -308,12 +308,12 @@ dnd.playpen-tab-drag {
|
||||
}
|
||||
|
||||
/* Status dots. One shared appearance for the sidebar row and the pane header,
|
||||
coloured entirely by which state class is on the icon. Symbolic icons take
|
||||
their colour from `color`, so nothing here needs a second asset.
|
||||
colored entirely by which state class is on the icon. Symbolic icons take
|
||||
their color from `color`, so nothing here needs a second asset.
|
||||
|
||||
These are read at a glance from across a screen full of tabs, so the colours
|
||||
These are read at a glance from across a screen full of tabs, so the colors
|
||||
are picked to survive that: amber and red carry the two states that actually
|
||||
want you, and they are the only warm colours anywhere in the window. */
|
||||
want you, and they are the only warm colors anywhere in the window. */
|
||||
.playpen-status-dot {
|
||||
-gtk-icon-size: 11px;
|
||||
opacity: 0;
|
||||
@@ -375,6 +375,23 @@ dnd.playpen-tab-drag {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* The rule between what a row *is* and whether it is allowed to interrupt you.
|
||||
Inset from the popover's edges so it reads as a divider inside a list rather
|
||||
than as the list being cut in two. */
|
||||
.playpen-row-menu > separator {
|
||||
margin: 3px 6px;
|
||||
background-color: @pp_border;
|
||||
}
|
||||
|
||||
/* What a tab's mute is currently doing, above the ways out of it. Sized and
|
||||
dimmed like the sublabels in the settings dialog, because it is the same kind
|
||||
of thing: a caption on the controls under it, not a control. */
|
||||
.playpen-row-menu-state {
|
||||
padding: 2px 8px;
|
||||
color: @pp_text_faint;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* A web pane's navigation bar, below the pane header. Kept visually quieter
|
||||
than the header so the two rows don't compete. */
|
||||
.playpen-nav {
|
||||
@@ -445,7 +462,7 @@ dnd.playpen-tab-drag {
|
||||
asking without being read pane by pane.
|
||||
|
||||
Only the three states that are news get a border. Working deliberately does
|
||||
not: it is the resting state of every pane you have set going, and colouring
|
||||
not: it is the resting state of every pane you have set going, and coloring
|
||||
the frame for it would leave the whole view repainting itself all afternoon
|
||||
while saying nothing you did not already know. Its pulsing dot is enough.
|
||||
|
||||
@@ -465,7 +482,7 @@ dnd.playpen-tab-drag {
|
||||
border-color: @pp_err;
|
||||
}
|
||||
|
||||
/* The header carries a wash of the same colour. The border alone is a hairline
|
||||
/* The header carries a wash of the same color. The border alone is a hairline
|
||||
around a large shape; the header is a solid band right next to the dot and
|
||||
the title, which is what makes a pane readable at a glance in a four-way
|
||||
split. */
|
||||
@@ -601,9 +618,9 @@ dnd.playpen-tab-drag {
|
||||
choice is visible without opening anything, and so the one you are on is
|
||||
readable at a glance rather than by reading a word. */
|
||||
/* `background-image: none` on every rule below is load-bearing. Adwaita paints
|
||||
button states with a gradient *over* the background colour, so setting the
|
||||
colour alone leaves a washed-out version of it — most visibly on the checked
|
||||
button, where the accent came out as a pale grey-blue. `:backdrop` is
|
||||
button states with a gradient *over* the background color, so setting the
|
||||
color alone leaves a washed-out version of it — most visibly on the checked
|
||||
button, where the accent came out as a pale gray-blue. `:backdrop` is
|
||||
spelled out for the same reason: Adwaita dims unfocused windows, and the
|
||||
selected scheme is exactly the thing that has to stay readable while you are
|
||||
looking at the window behind this one to see what it did. */
|
||||
@@ -639,11 +656,11 @@ dnd.playpen-tab-drag {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
The colour editor
|
||||
The color editor
|
||||
|
||||
One row per colour in the palette, in sections. The rows are deliberately
|
||||
One row per color in the palette, in sections. The rows are deliberately
|
||||
plain: on a page where thirty-nine swatches are the point, the swatch should
|
||||
be the only thing on a line carrying any colour, and a bordered card per
|
||||
be the only thing on a line carrying any color, and a bordered card per
|
||||
section would read as noise. The space between sections does that work
|
||||
instead. */
|
||||
|
||||
@@ -665,10 +682,10 @@ dnd.playpen-tab-drag {
|
||||
color: @pp_text_faint;
|
||||
}
|
||||
|
||||
/* Adwaita gives a colour button the full button treatment: a gradient over the
|
||||
colour, and a border in a grey belonging to neither palette. What is wanted
|
||||
is a rectangle of the colour with this app's border around it, so the swatch
|
||||
reads as the colour rather than as a button holding one. */
|
||||
/* Adwaita gives a color button the full button treatment: a gradient over the
|
||||
color, and a border in a gray belonging to neither palette. What is wanted
|
||||
is a rectangle of the color with this app's border around it, so the swatch
|
||||
reads as the color rather than as a button holding one. */
|
||||
.playpen-color-group colorbutton > button {
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
@@ -684,9 +701,9 @@ dnd.playpen-tab-drag {
|
||||
}
|
||||
|
||||
/* The contrast slider, for the same reason as the swatch above it: Adwaita
|
||||
paints a scale in the *desktop's* accent colour, which on a page whose whole
|
||||
subject is the colour this window is painted from would be the one thing on
|
||||
screen ignoring it. Left stock, dragging it towards a warm theme leaves a
|
||||
paints a scale in the *desktop's* accent color, which on a page whose whole
|
||||
subject is the color this window is painted from would be the one thing on
|
||||
screen ignoring it. Left stock, dragging it toward a warm theme leaves a
|
||||
blue bar sitting in the middle of the result. */
|
||||
.playpen-contrast trough {
|
||||
background-color: @pp_surface_raised_active;
|
||||
@@ -789,7 +806,7 @@ dnd.playpen-tab-drag {
|
||||
}
|
||||
|
||||
/* The glyph in force. `background-image: none` for the same reason as the theme
|
||||
buttons above: Adwaita paints selection with a gradient over the colour, and
|
||||
buttons above: Adwaita paints selection with a gradient over the color, and
|
||||
without this the accent comes out washed. */
|
||||
.playpen-emoji-grid > flowboxchild:selected,
|
||||
.playpen-emoji-grid > flowboxchild:selected:backdrop {
|
||||
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
//! The colours the terminal renderer draws with, resolved for the scheme the
|
||||
//! The colors the terminal renderer draws with, resolved for the scheme the
|
||||
//! app is currently in.
|
||||
//!
|
||||
//! Everything the *widgets* are painted with reaches them as named CSS colours
|
||||
//! Everything the *widgets* are painted with reaches them as named CSS colors
|
||||
//! (see `palette.zig` and `appearance.zig`). The terminal grid can't work that
|
||||
//! way: Cairo draws it directly and never consults the style tree, so a
|
||||
//! stylesheet reload on its own would leave every open terminal painted in the
|
||||
@@ -24,7 +24,7 @@ const palette = @import("palette.zig");
|
||||
pub const Rgb = palette.Rgb;
|
||||
pub const Scheme = palette.Scheme;
|
||||
|
||||
/// The colours of one scheme, already resolved through the user's overrides.
|
||||
/// The colors of one scheme, already resolved through the user's overrides.
|
||||
const Resolved = struct {
|
||||
/// Terminal default background, used when the program hasn't set one.
|
||||
bg: Rgb,
|
||||
@@ -32,19 +32,19 @@ const Resolved = struct {
|
||||
/// Terminal default foreground.
|
||||
fg: Rgb,
|
||||
|
||||
/// Cursor block colour.
|
||||
/// Cursor block color.
|
||||
cursor: Rgb,
|
||||
|
||||
/// Background and foreground of selected text. Both are fixed rather than
|
||||
/// derived from the cell underneath, because a selection has to read as one
|
||||
/// continuous block across text the program has coloured every which way —
|
||||
/// a translucent wash over the existing colours leaves the highlight
|
||||
/// looking like a rendering artefact wherever the text is already bright.
|
||||
/// continuous block across text the program has colored every which way —
|
||||
/// a translucent wash over the existing colors leaves the highlight
|
||||
/// looking like a rendering artifact wherever the text is already bright.
|
||||
selection_bg: Rgb,
|
||||
selection_fg: Rgb,
|
||||
|
||||
/// The full 256-colour palette. The 240 above index 16 are fixed by the
|
||||
/// xterm spec — a 6×6×6 cube and a grey ramp — and mean the same thing
|
||||
/// The full 256-color palette. The 240 above index 16 are fixed by the
|
||||
/// xterm spec — a 6×6×6 cube and a gray ramp — and mean the same thing
|
||||
/// whatever the background is, so they are left exactly as libghostty-vt
|
||||
/// built them; only the 16 named ones are ours to set.
|
||||
ansi: vt.color.Palette,
|
||||
|
||||
+52
-52
@@ -1,29 +1,29 @@
|
||||
//! One colour in, a whole palette out.
|
||||
//! One color in, a whole palette out.
|
||||
//!
|
||||
//! Forty swatches is a fair editor and a poor starting point. Almost nobody
|
||||
//! wants to choose a window backdrop, a sidebar, a pane surface, a pane header
|
||||
//! and two border weights — they want the app to be *green*, and for the six
|
||||
//! greys behind the green to be the six greys that go with it. That is what this
|
||||
//! module is: a base colour, and a rule for everything else.
|
||||
//! grays behind the green to be the six grays that go with it. That is what this
|
||||
//! module is: a base color, and a rule for everything else.
|
||||
//!
|
||||
//! It sits underneath the per-colour overrides rather than replacing them (see
|
||||
//! `palette.resolve`). Picking a base changes what every unedited colour
|
||||
//! resolves to; a colour that has been set by hand still wins. So the two ways
|
||||
//! It sits underneath the per-color overrides rather than replacing them (see
|
||||
//! `palette.resolve`). Picking a base changes what every unedited color
|
||||
//! resolves to; a color that has been set by hand still wins. So the two ways
|
||||
//! of theming compose in the order you would want them to — build the thing out
|
||||
//! of one colour, then go and fix the one swatch you don't like — and neither
|
||||
//! of one color, then go and fix the one swatch you don't like — and neither
|
||||
//! destroys the other's work.
|
||||
//!
|
||||
//! **How the rule works.** Every colour is one of three kinds:
|
||||
//! **How the rule works.** Every color is one of three kinds:
|
||||
//!
|
||||
//! * Most of them are *tinted*: they take the base's hue, a fraction of its
|
||||
//! colourfulness, and a fixed lightness. This is the structure of the theme —
|
||||
//! colorfulness, and a fixed lightness. This is the structure of the theme —
|
||||
//! the surfaces stacked from the backdrop up to the pane header, the three
|
||||
//! weights of text on them. A green base makes them faintly green, a grey
|
||||
//! base makes them grey, and their spacing never changes, because that
|
||||
//! weights of text on them. A green base makes them faintly green, a gray
|
||||
//! base makes them gray, and their spacing never changes, because that
|
||||
//! spacing is what tells a pane header from a pane.
|
||||
//! * Three of them are the *accent*: the base colour itself, and a lighter and a
|
||||
//! * Three of them are the *accent*: the base color itself, and a lighter and a
|
||||
//! more muted version of it. Picking `#3a76f0` gets you `#3a76f0` as the
|
||||
//! accent, which is the only behaviour that makes the colour picker feel like
|
||||
//! accent, which is the only behavior that makes the color picker feel like
|
||||
//! it did what you asked.
|
||||
//! * The rest are *named* — red, green, "failed", "waiting on you". A red that
|
||||
//! followed the base hue would not be red. These keep their own hue and take
|
||||
@@ -34,7 +34,7 @@
|
||||
//! scaling it about a midpoint. The difference matters in the light scheme,
|
||||
//! where the surfaces sit hard against white: scaling about a midpoint pushes
|
||||
//! them all through the ceiling and the pane, the pane header and the sidebar
|
||||
//! come out the same colour. Anchoring at the ends instead means the ramp can
|
||||
//! come out the same color. Anchoring at the ends instead means the ramp can
|
||||
//! only ever spread apart or draw together, which is the thing the setting
|
||||
//! claims to do.
|
||||
//!
|
||||
@@ -56,7 +56,7 @@ const Rgb = palette.Rgb;
|
||||
const Scheme = palette.Scheme;
|
||||
const Tint = palette.Tint;
|
||||
|
||||
/// The colour every unedited swatch in `scheme` resolves to under `tint`.
|
||||
/// The color every unedited swatch in `scheme` resolves to under `tint`.
|
||||
///
|
||||
/// Total for every key that doesn't inherit one — which is all of them that
|
||||
/// reach here, since `palette.resolve` follows inheritance before it gets this
|
||||
@@ -70,10 +70,10 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
|
||||
const ramp = lightness_ramp.get(scheme);
|
||||
|
||||
const shade: oklab.Lch = switch (stop.role) {
|
||||
// Structure: the base's hue, a share of its colourfulness, and a fixed
|
||||
// Structure: the base's hue, a share of its colorfulness, and a fixed
|
||||
// rung on the ladder. The share is a fraction rather than an absolute
|
||||
// so that a grey base yields a grey window — there is no floor here
|
||||
// that would make a deliberately colourless theme come out faintly
|
||||
// so that a gray base yields a gray window — there is no floor here
|
||||
// that would make a deliberately colorless theme come out faintly
|
||||
// blue.
|
||||
.tinted => .{
|
||||
.l = stop.l,
|
||||
@@ -81,9 +81,9 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
|
||||
.h = base.h,
|
||||
},
|
||||
|
||||
// The accent is the colour that was picked, moved only as far as it has
|
||||
// The accent is the color that was picked, moved only as far as it has
|
||||
// to be. The lightness clamp is the whole of that: someone who picks a
|
||||
// near-black as their base means "make it this colour", not "make the
|
||||
// near-black as their base means "make it this color", not "make the
|
||||
// one thing that has to stand out invisible", and the two lighter and
|
||||
// darker accents are then offsets from wherever it landed.
|
||||
.accent => .{
|
||||
@@ -92,10 +92,10 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
|
||||
.h = base.h,
|
||||
},
|
||||
|
||||
// A colour that has to stay the colour it is named after keeps its hue
|
||||
// A color that has to stay the color it is named after keeps its hue
|
||||
// outright, and takes from the base only how loud to be — measured
|
||||
// against the accent the shipped palette was tuned around. The floor
|
||||
// keeps a grey theme's "failed" marker distinguishable from its
|
||||
// keeps a gray theme's "failed" marker distinguishable from its
|
||||
// "finished" one; the ceiling keeps a neon base from producing sixteen
|
||||
// more neons.
|
||||
.named => .{
|
||||
@@ -116,23 +116,23 @@ pub fn derive(key: Key, scheme: Scheme, tint: Tint) Rgb {
|
||||
// -------------------------------------------------------------------------
|
||||
// Contrast
|
||||
|
||||
/// Move a lightness away from — or towards — the ends of its scheme's ramp.
|
||||
/// Move a lightness away from — or toward — the ends of its scheme's ramp.
|
||||
///
|
||||
/// The ramp's own ends are pushed out first, each towards the nearer end of the
|
||||
/// The ramp's own ends are pushed out first, each toward the nearer end of the
|
||||
/// scale and by the same *share of the room it has left*, and everything in
|
||||
/// between is carried along by the affine map that takes the old ends to the new
|
||||
/// ones. Two things fall out of that shape and both are the point:
|
||||
///
|
||||
/// * Nothing can be pushed past black or white, so nothing clips. A ramp that
|
||||
/// clipped would not merely stop responding at the top — it would collapse the
|
||||
/// colours nearest the ceiling into each other, which in the light scheme is
|
||||
/// colors nearest the ceiling into each other, which in the light scheme is
|
||||
/// the pane, the pane header and the sidebar becoming one flat white.
|
||||
/// * Every gap in the ramp scales by the same factor. Turning contrast up
|
||||
/// separates the pane from the backdrop and the text from the pane by the same
|
||||
/// proportion, rather than doing something dramatic at one end of the ladder
|
||||
/// and nothing at the other.
|
||||
///
|
||||
/// `spread` is how much of that a given colour takes. The structural colours
|
||||
/// `spread` is how much of that a given color takes. The structural colors
|
||||
/// take all of it; an accent or a red takes about half, because past a point
|
||||
/// contrast stops making them starker and starts making them not red.
|
||||
fn stretch(l: f32, contrast: f32, ramp: Ramp, spread: f32) f32 {
|
||||
@@ -149,21 +149,21 @@ fn stretch(l: f32, contrast: f32, ramp: Ramp, spread: f32) f32 {
|
||||
/// becomes bare white.
|
||||
const contrast_reach = 0.5;
|
||||
|
||||
/// Contrast drains a little colour out of the structural surfaces as it goes up
|
||||
/// — a starker theme is a cleaner one — and puts a little back into the colours
|
||||
/// Contrast drains a little color out of the structural surfaces as it goes up
|
||||
/// — a starker theme is a cleaner one — and puts a little back into the colors
|
||||
/// whose job is to be seen.
|
||||
const chroma_calm = 0.20;
|
||||
const chroma_lift = 0.12;
|
||||
|
||||
/// The chroma of the accent both shipped palettes are built around, which is
|
||||
/// what a `named` colour's intensity is measured against. A base this colourful
|
||||
/// leaves the status and terminal colours exactly as the tables below name them.
|
||||
/// what a `named` color's intensity is measured against. A base this colorful
|
||||
/// leaves the status and terminal colors exactly as the tables below name them.
|
||||
const reference_chroma = 0.1944;
|
||||
|
||||
/// The two ends of a scheme's lightness ladder, and the band an accent has to
|
||||
/// land in to be legible against it.
|
||||
const Ramp = struct {
|
||||
/// The lightness of the colour furthest into the background — the window
|
||||
/// The lightness of the color furthest into the background — the window
|
||||
/// backdrop in the dark scheme, the body text in the light one.
|
||||
lo: f32,
|
||||
|
||||
@@ -188,7 +188,7 @@ const Role = enum {
|
||||
accent,
|
||||
named,
|
||||
|
||||
/// How much of the contrast stretch this kind of colour takes. See
|
||||
/// How much of the contrast stretch this kind of color takes. See
|
||||
/// `stretch`.
|
||||
fn spread(self: Role) f32 {
|
||||
return switch (self) {
|
||||
@@ -199,20 +199,20 @@ const Role = enum {
|
||||
}
|
||||
};
|
||||
|
||||
/// One colour's recipe in one scheme. What the three numbers mean depends on the
|
||||
/// One color's recipe in one scheme. What the three numbers mean depends on the
|
||||
/// role, which is why they are named for their role rather than for OKLCh.
|
||||
const Stop = struct {
|
||||
role: Role,
|
||||
|
||||
/// `tinted` and `named`: the lightness outright. `accent`: an offset from
|
||||
/// the base colour's own.
|
||||
/// the base color's own.
|
||||
l: f32,
|
||||
|
||||
/// `tinted` and `accent`: a multiple of the base colour's chroma. `named`:
|
||||
/// a chroma outright, scaled by how colourful the base is.
|
||||
/// `tinted` and `accent`: a multiple of the base color's chroma. `named`:
|
||||
/// a chroma outright, scaled by how colorful the base is.
|
||||
c: f32,
|
||||
|
||||
/// `named` only: the hue that makes it the colour it is named after.
|
||||
/// `named` only: the hue that makes it the color it is named after.
|
||||
h: f32 = 0,
|
||||
};
|
||||
|
||||
@@ -220,7 +220,7 @@ fn tinted(l: f32, c: f32) Stop {
|
||||
return .{ .role = .tinted, .l = l, .c = c };
|
||||
}
|
||||
|
||||
/// `dl` is measured from the base colour's lightness, so that the three accents
|
||||
/// `dl` is measured from the base color's lightness, so that the three accents
|
||||
/// stay the same distance apart wherever the base sits.
|
||||
fn accent(dl: f32, c: f32) Stop {
|
||||
return .{ .role = .accent, .l = dl, .c = c };
|
||||
@@ -234,7 +234,7 @@ const Stops = std.enums.EnumArray(Key, ?Stop);
|
||||
|
||||
/// Named because it has to be typed as an *inner* null: an outer one would mean
|
||||
/// "this table has no stop for anything". A key absent from both tables is one
|
||||
/// that inherits its colour from another and never reaches this module.
|
||||
/// that inherits its color from another and never reaches this module.
|
||||
const inherited: ?Stop = null;
|
||||
|
||||
fn stops(scheme: Scheme) *const Stops {
|
||||
@@ -278,9 +278,9 @@ const dark_stops: Stops = .initDefault(inherited, .{
|
||||
// the mid one, for the reason `palette.Key.hint` gives.
|
||||
.term_cursor = accent(0.0742, 0.797),
|
||||
|
||||
// The four neutral ANSI colours are tinted rather than named: "black" and
|
||||
// "white" here are the ends of the terminal's own greyscale, and a terminal
|
||||
// whose greys are a different grey from the window around it looks like a
|
||||
// The four neutral ANSI colors are tinted rather than named: "black" and
|
||||
// "white" here are the ends of the terminal's own grayscale, and a terminal
|
||||
// whose grays are a different gray from the window around it looks like a
|
||||
// pane that failed to load.
|
||||
.ansi_black = tinted(0.2380, 0.190),
|
||||
.ansi_red = named(0.6308, 0.150, 21.4),
|
||||
@@ -353,7 +353,7 @@ const light_stops: Stops = .initDefault(inherited, .{
|
||||
|
||||
// A key has a stop in both tables or in neither, and which it is has to be the
|
||||
// same answer `palette.Key.inherits` gives — otherwise a generated palette
|
||||
// either has a hole in it that `derive` walks into, or quietly stops honouring
|
||||
// either has a hole in it that `derive` walks into, or quietly stops honoring
|
||||
// an inheritance the rest of the app is written against.
|
||||
comptime {
|
||||
for (std.enums.values(Key)) |key| {
|
||||
@@ -370,7 +370,7 @@ comptime {
|
||||
const testing = std.testing;
|
||||
|
||||
/// The largest difference in any channel, which is the useful measure here: a
|
||||
/// generated colour is right if you cannot tell it from the one it is standing
|
||||
/// generated color is right if you cannot tell it from the one it is standing
|
||||
/// in for, and a couple of steps in one channel is well inside that.
|
||||
fn drift(a: Rgb, b: Rgb) u16 {
|
||||
return @max(
|
||||
@@ -395,7 +395,7 @@ test "the accent regenerates the palette it was taken from" {
|
||||
const generated = derive(key, scheme, tint);
|
||||
const shipped = key.default(scheme);
|
||||
|
||||
// Wider for the ANSI colours: those stops are a deliberate retune
|
||||
// Wider for the ANSI colors: those stops are a deliberate retune
|
||||
// rather than a transcription — libghostty-vt's dark sixteen are
|
||||
// washed out beside a full-strength accent, and the shipped light
|
||||
// cyan is duller than the rest of its row.
|
||||
@@ -412,8 +412,8 @@ test "the accent regenerates the palette it was taken from" {
|
||||
}
|
||||
}
|
||||
|
||||
test "the accent is the colour that was picked" {
|
||||
// Anything else makes the picker feel broken. The only licence taken is the
|
||||
test "the accent is the color that was picked" {
|
||||
// Anything else makes the picker feel broken. The only license taken is the
|
||||
// lightness clamp, and these two sit well inside it.
|
||||
for ([_][]const u8{ "#3a76f0", "#d2691e" }) |text| {
|
||||
const base = Rgb.parse(text).?;
|
||||
@@ -449,13 +449,13 @@ test "the surfaces stay in order, whatever the base and the contrast" {
|
||||
const l = oklab.fromRgb(derive(key, scheme, tint)).l;
|
||||
|
||||
// Light steps *down* from its pane surface for the two
|
||||
// raised colours, so it is the size of each step that has to
|
||||
// raised colors, so it is the size of each step that has to
|
||||
// hold rather than the direction of all of them.
|
||||
//
|
||||
// The floor is low because the tightest step in the shipped
|
||||
// dark palette — the sidebar against the pane beside it — is
|
||||
// itself only 0.014, those two being separated as much by
|
||||
// the gutter between them as by their colours. Softening the
|
||||
// the gutter between them as by their colors. Softening the
|
||||
// contrast draws that to about 0.012, which is the number
|
||||
// this has to sit under. What it is really watching for is a
|
||||
// step going to nothing.
|
||||
@@ -503,7 +503,7 @@ test "contrast spreads the ramp without pushing anything off the end" {
|
||||
try testing.expect(soft < level);
|
||||
try testing.expect(level < stark);
|
||||
|
||||
// And the colours nearest the ceiling stay distinct rather than piling
|
||||
// And the colors nearest the ceiling stay distinct rather than piling
|
||||
// up against it, which is what a ramp that scaled about its middle would
|
||||
// do to the light scheme.
|
||||
const tint: Tint = .{ .base = base, .contrast = 1 };
|
||||
@@ -518,11 +518,11 @@ fn span(scheme: Scheme, tint: Tint) f32 {
|
||||
oklab.fromRgb(derive(.bg, scheme, tint)).l);
|
||||
}
|
||||
|
||||
test "a grey base makes a grey theme, and keeps the status colours apart" {
|
||||
test "a gray base makes a gray theme, and keeps the status colors apart" {
|
||||
const tint: Tint = .{ .base = Rgb.parse("#808080").? };
|
||||
|
||||
for (std.enums.values(Scheme)) |scheme| {
|
||||
// No floor sneaking colour back into a theme that asked for none.
|
||||
// No floor sneaking color back into a theme that asked for none.
|
||||
for ([_]Key{ .bg, .surface, .text, .accent }) |key| {
|
||||
try testing.expect(oklab.fromRgb(derive(key, scheme, tint)).c < 0.01);
|
||||
}
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ just over half of those are the same gesture five more times; a grid of them is
|
||||
harder to look through, not more complete. Every base glyph is present, which
|
||||
is the same choice GTK's own emoji chooser, iOS and Slack all make.
|
||||
|
||||
So is anything newer than MAX_VERSION. Colour emoji fonts trail Unicode by a
|
||||
So is anything newer than MAX_VERSION. Color emoji fonts trail Unicode by a
|
||||
year or two, and a glyph the font has never heard of draws as a hex-digit box —
|
||||
which in a picker reads as a bug rather than as a font that needs updating. At
|
||||
the time of writing, Noto Color Emoji could draw all but 7 of the E17.0
|
||||
@@ -50,7 +50,7 @@ EMOJI_TEST = "https://unicode.org/Public/emoji/latest/emoji-test.txt"
|
||||
ANNOTATIONS = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotations/en.xml"
|
||||
DERIVED = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotationsDerived/en.xml"
|
||||
|
||||
# Terms no data file will ever give you: what a glyph means to someone labelling
|
||||
# Terms no data file will ever give you: what a glyph means to someone labeling
|
||||
# a terminal tab. CLDR knows a rocket is a "space ship"; it does not know it is
|
||||
# what you reach for when the tab is a deploy.
|
||||
SYNONYMS = {
|
||||
@@ -305,7 +305,7 @@ HEADER = '''//! Every emoji a tab can wear in place of its icon, and the search
|
||||
//! Unicode lists nearly twice this many fully-qualified sequences and the
|
||||
//! difference is almost entirely the same gesture in five tones, which makes a
|
||||
//! grid longer to look through without making it more complete. The version
|
||||
//! cutoff is there because colour emoji fonts trail Unicode, and a glyph the
|
||||
//! cutoff is there because color emoji fonts trail Unicode, and a glyph the
|
||||
//! font has never heard of draws as a hex-digit box; the generator's header
|
||||
//! explains how to raise it.
|
||||
//!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- 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
|
||||
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/,
|
||||
which web/embed.go ships inside the binary. -->
|
||||
<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
@@ -213,7 +213,7 @@ export default function App() {
|
||||
|
||||
// The parsed diff lives here rather than in DiffView because the comments rail
|
||||
// needs it too: deciding which comments the diff can still place is one
|
||||
// judgement, made once, so the rail and the diff can't disagree about it.
|
||||
// judgment, made once, so the rail and the diff can't disagree about it.
|
||||
const parsedFiles = useMemo(() => (payload ? parseDiff(payload.patch) : []), [payload]);
|
||||
|
||||
// What each file's diff currently says, digested. Viewed marks are stored
|
||||
@@ -395,20 +395,20 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (!path || !revision || stale) return;
|
||||
|
||||
let cancelled = false;
|
||||
let canceled = false;
|
||||
let busy = false;
|
||||
|
||||
const check = async () => {
|
||||
// A hidden pane is a pane nobody is reading. It gets checked the moment it
|
||||
// comes back instead, which is when the answer matters.
|
||||
if (cancelled || busy || document.hidden) return;
|
||||
if (canceled || busy || document.hidden) return;
|
||||
busy = true;
|
||||
const seq = reqRef.current;
|
||||
try {
|
||||
const { revision: now } = await api.revision(ctx, { ignoreWhitespace: ignoreWs });
|
||||
// A load that started while this was in flight has already answered the
|
||||
// question, with a revision this closure doesn't know about.
|
||||
if (cancelled || seq !== reqRef.current) return;
|
||||
if (canceled || seq !== reqRef.current) return;
|
||||
if (now && now !== revision) setStale(now);
|
||||
} catch {
|
||||
// A failed poll says nothing about the diff — the next one will.
|
||||
@@ -424,7 +424,7 @@ export default function App() {
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('focus', onVisible);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
canceled = true;
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('focus', onVisible);
|
||||
|
||||
@@ -228,7 +228,7 @@ function SingleThread({
|
||||
}
|
||||
|
||||
// BodyEditor edits a comment's text in place of its rendered body. It starts
|
||||
// from the saved text and only reports a change on save, so cancelling always
|
||||
// from the saved text and only reports a change on save, so canceling always
|
||||
// leaves the stored comment untouched.
|
||||
function BodyEditor({
|
||||
initial,
|
||||
|
||||
@@ -206,12 +206,12 @@ function FileView({
|
||||
setOldSource(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let canceled = false;
|
||||
api.fileContent(base, file.oldPath).then((s) => {
|
||||
if (!cancelled) setOldSource(s);
|
||||
if (!canceled) setOldSource(s);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
canceled = true;
|
||||
};
|
||||
}, [base, file.oldPath, file.type]);
|
||||
|
||||
@@ -227,14 +227,14 @@ function FileView({
|
||||
// Highlighting is done over the *whole* file, never over the visible hunks
|
||||
// alone. Prism is a stateful tokenizer: a construct that opens above the first
|
||||
// visible line — a block comment, a template literal, a heredoc — leaves it in
|
||||
// the wrong state and mis-colours everything after it, so what got highlighted
|
||||
// the wrong state and mis-colors everything after it, so what got highlighted
|
||||
// would depend on which context happened to be collapsed. Handing it the base
|
||||
// source (react-diff-view derives the head side by applying `hunks`) makes the
|
||||
// result identical no matter what is expanded.
|
||||
//
|
||||
// A wholly added or deleted file needs no base source: its hunks already carry
|
||||
// every line, so tokenizing them is exact. Otherwise we wait for the fetch
|
||||
// rather than highlight a fragment — a beat of plain text beats wrong colours.
|
||||
// rather than highlight a fragment — a beat of plain text beats wrong colors.
|
||||
const tokens: HunkTokens | undefined = useMemo(() => {
|
||||
const lang = languageForFile(path);
|
||||
if (!lang) return undefined;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// pulled in as a dependency because we need a dozen of ~600, and a local table
|
||||
// keeps the icon set visible in one place instead of hidden behind imports.
|
||||
//
|
||||
// Every glyph is authored on a 16×16 grid with `fill: currentColor`, so colour
|
||||
// comes from the surrounding text colour and size from the `size` prop.
|
||||
// Every glyph is authored on a 16×16 grid with `fill: currentColor`, so color
|
||||
// comes from the surrounding text color and size from the `size` prop.
|
||||
|
||||
const PATHS = {
|
||||
'chevron-down':
|
||||
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
/* ============================================================================
|
||||
review — local code review UI
|
||||
|
||||
The styling is GitHub's, not an interpretation of it. Every colour below is a
|
||||
The styling is GitHub's, not an interpretation of it. Every color below is a
|
||||
Primer token: the dark block is Primer's `dark dimmed` theme and the light
|
||||
block is `light`, both at the values GitHub currently ships. The diff
|
||||
geometry — 12px/24px mono rows, 45px line-number columns, a 22px marker
|
||||
@@ -63,7 +63,7 @@
|
||||
--attention-soft: rgba(174, 124, 20, 0.15);
|
||||
--attention-muted: rgba(174, 124, 20, 0.4);
|
||||
|
||||
/* Diff blob colours, sampled from a rendered diff. */
|
||||
/* Diff blob colors, sampled from a rendered diff. */
|
||||
--add-bg: #263834; /* addition, code cell */
|
||||
--add-gutter: #31503d; /* addition, line number cell */
|
||||
--add-word: #31583c; /* addition, word-level mark */
|
||||
@@ -1153,7 +1153,7 @@ body.is-resizing {
|
||||
border-left-color: var(--claude);
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* An outdated card keeps its status colour on the pill but takes the attention
|
||||
/* An outdated card keeps its status color on the pill but takes the attention
|
||||
edge, since "can't find the code any more" is what you need to notice first. */
|
||||
.comment-card.is-outdated {
|
||||
border-left-color: var(--attention);
|
||||
@@ -1251,7 +1251,7 @@ body.is-resizing {
|
||||
}
|
||||
|
||||
/* What stands in for a diff too large to render, once its warning has been
|
||||
dismissed — the same centred treatment as .loading, plus the way back in. */
|
||||
dismissed — the same centered treatment as .loading, plus the way back in. */
|
||||
.oversize-notice {
|
||||
max-width: 560px;
|
||||
margin: 64px auto;
|
||||
@@ -1370,7 +1370,7 @@ body.is-resizing {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Primer Label: 999px pill, coloured border, 12px text. */
|
||||
/* Primer Label: 999px pill, colored border, 12px text. */
|
||||
.file-status {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
@@ -1468,7 +1468,7 @@ body.is-resizing {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* A viewed file steps back: its header greys out so the ones still to read are
|
||||
/* A viewed file steps back: its header grays out so the ones still to read are
|
||||
what the eye lands on. */
|
||||
.file.is-viewed {
|
||||
border-color: var(--border-muted);
|
||||
@@ -1529,7 +1529,7 @@ body.is-resizing {
|
||||
}
|
||||
|
||||
/* ---- Outdated comments -------------------------------------------------- */
|
||||
/* Attention yellow, the same colour the gutter uses for a commented line: the
|
||||
/* Attention yellow, the same color the gutter uses for a commented line: the
|
||||
comment is intact, only its anchor is gone. Nothing here reads as an error. */
|
||||
.outdated-note {
|
||||
padding: 12px 16px;
|
||||
@@ -1719,7 +1719,7 @@ body.is-resizing {
|
||||
}
|
||||
|
||||
/* A range being dragged, or one a pending comment is attached to. GitHub tints
|
||||
commented lines with attention yellow rather than the accent colour. */
|
||||
commented lines with attention yellow rather than the accent color. */
|
||||
.diff-gutter-selected {
|
||||
background: var(--attention-muted);
|
||||
color: var(--text);
|
||||
@@ -1812,7 +1812,7 @@ body.is-resizing {
|
||||
}
|
||||
|
||||
/* ---- Primer prettylights syntax theme ----------------------------------- */
|
||||
/* Prism's token names mapped onto GitHub's syntax colours. Both places code is
|
||||
/* Prism's token names mapped onto GitHub's syntax colors. Both places code is
|
||||
highlighted share them: the diff, and a fenced block in a comment. */
|
||||
:is(.diff, .md) .token.comment,
|
||||
:is(.diff, .md) .token.prolog,
|
||||
|
||||
Reference in New Issue
Block a user