aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 23:06:32 +0200
committeruser <user@clank>2026-08-15 23:06:32 +0200
commitf6281fbec93d92bfc7e9164492e436d0236d9179 (patch)
treec78b435dec7f62c948e2e8144f29f47ccab2fa7d /src
parentsl0ppty: a command palette, on a key you choose (diff)
downloadslosh-f6281fbec93d92bfc7e9164492e436d0236d9179.tar.gz
slosh-f6281fbec93d92bfc7e9164492e436d0236d9179.tar.xz
slosh-f6281fbec93d92bfc7e9164492e436d0236d9179.zip
sl0ppty: shaders on the chrome, and two keys for the arrangement
Five things, in one commit because they land in the same few files -- the action table, the shader pass, config.kdl -- and splitting them by hunk would buy a tidier log at the price of commits that do not build. Each is verified end to end; the suite is 46 files green. Chrome shaders. `where="chrome"` on any entry in `shaders` or in a `states` block runs that pass over a pane's frame -- border, padding, title, buttons -- instead of its contents. Nothing else changes: same registry, same `amount`, same expressions, same chain order, one new word, because a frame is another rect and so it is another pass. It runs over the frame's whole rect with the contents punched out as a hole, rather than four passes over four sides: an effect has to be able to travel round a border, and four passes would each count x/y from zero and restart at every corner. The hole keeps the two honest -- a skipped cell is never visited, so a full-strength chrome tint cannot reach your text. It is also the only kind of pass that can colour a pane a small window has collapsed to a single row (D6), which is the state where a colour is worth the most. `channel="fg"|"bg"|"both"` came out of looking at the first border flash: `tint` mixes both of a cell's colours, and a frame's background is the terminal's own default, so the pass materialised it to our guess at that default and mixed towards the colour -- a dark rectangle behind the glyphs, left there until the pane changed state. The mask is enforced by the pass, not the shader: the other colour is snapshotted before materialising and put back after, so an unset colour goes back to unset, it works for a built-in and a plugin alike, and no shader grows an fg-only twin of itself. A `bell` pane state and a `since` variable, because "flash the border when a pane rings" needed both and neither existed. `since` is milliseconds since the pane entered its current state, stamped in the one function that decides the state -- a transition is the one thing a frame cannot tell you, so it is remembered where it cannot disagree with the state it dates. `bell` ranks above the ambient states and below `dead`: a pane that rang while you were elsewhere is news, and a pane that rang and then exited is a pane that exited. It ends when you look at the pane, because that is what answering a bell is. Animation needed a clock and did not have one: a shader reading `t` only advanced when something else caused a frame, so a pulse froze the moment you stopped typing. `app_next_deadline_ms` now reports `anim_ms` while a chain that reads the clock ran over the last frame -- derived from what was applied, so a pulse hung off `dead` costs a clock while a pane is dead and nothing for the rest of the session. A `deadline` verb makes that observable; the server's poll loop already lived on the number. contrib/chrome has seven presets and the perimeter trick they are built on: on a frame the coordinate you want is distance along the ring, which the language can work out, and `min(d, P - d)` is what makes an effect cross the corner it started from. Hand-written, unlike contrib/shaders, because the toy previews contents and a frame has no preview to give -- so tests/test_chrome_presets.py checks each one the only way left: load it into a real session, assert the frame moves and the contents do not. `equalize` (C-a =) puts every share back to even: each split's children weighted by how many visible panes are behind them, so one pane beside a stack of three is a quarter of the width and "even" means the same thing at every depth. Exactly equal cell counts are not something a tree can always express, so the action promises the even division it can. `rotate-layout` (C-a Space) turns the whole tab a quarter turn clockwise. Every split changes axis and a row split's children reverse, because the pane on top of a stack is the pane on the right once you turn it; get that asymmetry wrong and you have a mirror image, which looks almost right until four turns fail to come back. They do come back, exactly, which is why the inverse is three more turns rather than a second mirrored routine with its own chance of being wrong -- and why a key as easy as space is the right one rather than a hazard. A turn is refused when it would not fit: a share of the height is not always a share the width can afford, and a node that cannot give its children their floor takes the whole tab down to a list. So the turn is tried, the layout is asked what it made of it, and one that flattened the tab is put back with a word. Same rule `split_fits` applies to splitting. Keys: the palette moves from `shift+p` to `p`, and cycling tabs from `n`/`p` to tab/shift+tab. `p` is pressed far more often than "the tab before this one" and was the only shifted letter in the defaults; tab is what every other tabbed thing cycles with, and `\e[Z` decodes as shift+tab on a plain terminal, so this costs nothing on a client without the kitty protocol. `n` is free again. The actions are untouched: two lines of config put the old binds back. The shader ABI goes to 3: `shade_ctx_t` gained `state_ms` and struct shader gained `channels`, both the pass's business rather than a shader's. Plugins need a rebuild, which is what the version is for.
Diffstat (limited to 'src')
-rw-r--r--src/app.c314
-rw-r--r--src/app.h22
-rw-r--r--src/cmd.c13
-rw-r--r--src/config.c122
-rw-r--r--src/config.h26
-rw-r--r--src/expr.c12
-rw-r--r--src/expr.h17
-rw-r--r--src/shader.c49
-rw-r--r--src/shader.h15
-rw-r--r--src/shader_abi.h29
-rw-r--r--src/sl0ppty.h7
11 files changed, 566 insertions, 60 deletions
diff --git a/src/app.c b/src/app.c
index 846d005..48ec42d 100644
--- a/src/app.c
+++ b/src/app.c
@@ -12,6 +12,7 @@
#include <stdlib.h>
#include <string.h>
+#include "expr.h"
#include "json.h"
#include "version.h"
#include "graphics.h"
@@ -141,6 +142,16 @@ struct node {
char purpose[64];
bool purpose_locked; /* declared by a layout: in-band cannot override */
+ /* When this pane last became what it is, for `since` (D20). A *transition*
+ * cannot be derived from the frame in front of you — the frame only says
+ * what is true now — so this is remembered, and it is remembered in the one
+ * place that decides the state, which is what keeps the timestamp and the
+ * state it belongs to from disagreeing. `last_state` is the state it was in
+ * when `state_since` was stamped; PSTATE_COUNT is "in none of them", which
+ * is a state to have been in for a while like any other. */
+ pane_state_t last_state;
+ int64_t state_since;
+
/* split */
split_dir_t dir;
node_t **kids;
@@ -296,11 +307,20 @@ struct app {
* socket under that name; the app only knows it because it is worth saying
* out loud when several are running. */
char session[64];
+ /* The last layout pass made this tab a list rather than a layout (D6). Same
+ * rule as `animating`: derived every pass, never remembered across one. */
+ bool flattened;
/* the screen we last composed into: its hit list is what a click resolves
* against, so routing can never consult geometry the user never saw */
corner_t corners[16];
size_t ncorners;
const screen_t *painted;
+ /* Something on the frame we last composed is animated: a shader whose amount
+ * reads the clock ran over it. Derived every frame from the chains actually
+ * applied, like the rect and the collapsed flag and for the same reason —
+ * remembering "this session animates" would keep a clock running for a pulse
+ * that was hung off a state no pane is in any more. */
+ bool animating;
};
static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; }
@@ -386,10 +406,13 @@ static void toasts_expire(app_t *a) {
a->ntoasts = keep;
}
-/* When something needs repainting on its own: a toast expiring, or a hover
- * guide arming. Without the second one the guide would appear on the next
- * event rather than when the dwell is up, which for a resting pointer means
- * "never". */
+/* When something needs repainting on its own: a toast expiring, a hover guide
+ * arming, or an animated shader — the three things on screen that change
+ * without an event behind them. Without the second one the guide would appear
+ * on the next event rather than when the dwell is up, which for a resting
+ * pointer means "never"; without the third a pulse would only advance when
+ * something else happened to cause a frame, which for an idle pane means a
+ * border frozen mid-pulse. */
int app_next_deadline_ms(app_t *a) {
int64_t soonest = -1;
for (size_t i = 0; i < a->ntoasts; i++)
@@ -409,6 +432,16 @@ int app_next_deadline_ms(app_t *a) {
}
}
+ /* An animated shader ran over the last frame, so the next thing that changes
+ * is the clock. Bounded by a config knob rather than painting flat out: this
+ * is a terminal, and 20fps of one pane's border is not worth a core. The
+ * flag comes from the frame we composed, so the cost lands only while
+ * something animated is actually on screen. */
+ if (a->animating && CFG.anim_ms) {
+ int64_t due = now_ms_() + CFG.anim_ms;
+ if (soonest < 0 || due < soonest) soonest = due;
+ }
+
if (soonest < 0) return -1;
int64_t in = soonest - now_ms_();
return in <= 0 ? 0 : (int)in;
@@ -1187,6 +1220,11 @@ static size_t tab_of(app_t *a, node_t *n);
static void layout(app_t *a) {
if (!a->ntabs) return;
+ /* Re-derived below, like everything else about a layout pass: whether this
+ * tab is a layout or a list (D6). Recorded because a tree edit needs to be
+ * able to ask what its edit did — a turn that flattens the tab is a turn
+ * nobody asked for. */
+ a->flattened = false;
uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap;
uint16_t top = (uint16_t)(gy + STRIP_ROWS);
rect_t r = {.x = gx,
@@ -1263,6 +1301,7 @@ static void layout(app_t *a) {
collapse_leaf(mins[i], (rect_t){r.x, r.y, 0, 0});
return;
}
+ a->flattened = true;
cur(a)->min_bar = (rect_t){0, 0, 0, 0}; /* a list has no separate bar */
if (r.h >= (uint16_t)(count_leaves(root) + 2)) {
/* One row per pane, plus a body worth opening into: a frame is two rows. */
@@ -2060,6 +2099,139 @@ static void resize_focus(app_t *a, int dx, int dy) {
layout(a);
}
+/* ---- equalising ---------------------------------------------------------
+ *
+ * Every visible pane an even share of the rows and columns it competes for.
+ *
+ * A split's children are weighted by *how many visible panes are behind each
+ * of them*, not given a weight each: one pane beside a column of three is a
+ * quarter of the width, not half of it. Weighting per child instead would make
+ * "even" mean something different at every depth — the pane on its own would
+ * get as much room as the three sharing the other side, which is the layout a
+ * person asks for this action to get *away* from.
+ *
+ * Exactly equal cell counts are not something a tree can always express: a
+ * nested split pays for its own gaps out of its share, and a share that is not
+ * a whole number of cells lands where the layout's remainder loop puts it. So
+ * this is the even division the tree *can* express, which is why the action is
+ * "give every pane an even share" and not "make every pane the same size".
+ *
+ * Minimised panes are not counted. They are out of the layout entirely and
+ * cost a row in the strip rather than a share of the tree, so counting one
+ * would hand its share to a pane nobody can see. Their own weight is still
+ * evened, because that is the share they come back to.
+ *
+ * Returns the number of visible panes under `n`. */
+static size_t equalize_node(node_t *n) {
+ if (n->kind == NODE_LEAF) {
+ n->weight = WEIGHT_UNIT;
+ return n->minimized ? 0 : 1;
+ }
+ size_t vis = 0;
+ for (size_t i = 0; i < n->nkids; i++) vis += equalize_node(n->kids[i]);
+ /* A subtree with nothing visible in it is not laid out at all, so its weight
+ * is never divided by — but it must not be zero either, or restoring the
+ * pane inside it would give it no share to come back to. */
+ n->weight = (int)(WEIGHT_UNIT * (vis ? vis : 1));
+ return vis;
+}
+
+/* The current tab, like zooming and minimising: this is about the arrangement
+ * you are looking at. False when there are no boundaries to move, so the
+ * caller can say so rather than leaving a keystroke looking broken. */
+bool app_equalize_splits(app_t *a) {
+ node_t *root = cur(a)->root;
+ if (!root || root->kind == NODE_LEAF) return false;
+ equalize_node(root);
+ layout(a);
+ return true;
+}
+
+/* ---- rotating -----------------------------------------------------------
+ *
+ * A quarter turn clockwise, of the whole tab.
+ *
+ * Every split changes axis, and one of the two directions also reverses its
+ * children — because that is what a quarter turn does to a stack. Turn a column
+ * of panes clockwise and the one that was on top is now the one on the *right*,
+ * so a row split becomes a column split read backwards; turn a row of them and
+ * the leftmost becomes the top, so the order stands. Getting that asymmetry
+ * wrong gives you a mirror image, which looks almost right and is not a
+ * rotation: four turns would not come back.
+ *
+ * Four turns *do* come back, exactly, which is what makes this safe to press:
+ * it is a permutation of the tree and nothing else. Weights travel with the
+ * children they belong to, so a pane that had two thirds of the width has two
+ * thirds of the height afterwards.
+ *
+ * Depth first, so a subtree is turned before the split above it reorders it —
+ * the two operations are independent, but doing the children first keeps the
+ * reversal reading as one thing rather than as something that has to be
+ * reasoned about twice. */
+static void rotate_node(node_t *n) {
+ if (!n || n->kind == NODE_LEAF) return;
+ for (size_t i = 0; i < n->nkids; i++) rotate_node(n->kids[i]);
+
+ if (n->dir == SPLIT_ROWS) {
+ for (size_t i = 0, j = n->nkids ? n->nkids - 1 : 0; i < j; i++, j--) {
+ node_t *t = n->kids[i];
+ n->kids[i] = n->kids[j];
+ n->kids[j] = t;
+ }
+ n->dir = SPLIT_COLS;
+ } else {
+ n->dir = SPLIT_ROWS;
+ }
+}
+
+/* The current tab, like zooming and equalising. False when there is nothing to
+ * turn — one pane looks the same from every angle — and false when the turn
+ * would not fit, which is the interesting case.
+ *
+ * A pane's share of one axis is not a share the other axis can always afford:
+ * two rows split 19:6 are fine as rows and put the smaller one under the column
+ * floor as columns, and a node that cannot give its children their floor takes
+ * the *whole tab* down to a list (D6). So the turn is tried, the layout is asked
+ * what it made of it, and a turn that flattened the tab is put back — the same
+ * rule `split_fits` applies to splitting, and for the same reason: an action a
+ * person just asked for should not be the thing that breaks the arrangement.
+ *
+ * Put back by turning three more times rather than by a second, mirrored
+ * rotation. Four turns are the identity, so this *is* the inverse, and the
+ * asymmetry between the two directions — the part that is easy to get wrong —
+ * stays written down exactly once.
+ *
+ * A tab that was *already* a list is turned anyway: there is nothing to break,
+ * and refusing would mean a window too small to lay out could never be turned
+ * back into one that is. */
+bool app_rotate_layout(app_t *a) {
+ node_t *root = cur(a)->root;
+ if (!root || root->kind == NODE_LEAF) return false;
+
+ bool was_flat = a->flattened;
+ rotate_node(root);
+ layout(a);
+ if (a->flattened && !was_flat) {
+ rotate_node(root);
+ rotate_node(root);
+ rotate_node(root);
+ layout(a);
+ return false;
+ }
+ return true;
+}
+
+/* Turning because a person just asked for it, which is the only case that needs
+ * telling why nothing happened. The two refusals are different facts and get
+ * different words: one pane has no arrangement to turn, and a turn that would
+ * flatten the tab was put back. Same split of responsibility as
+ * `split_focus_ui`: the tree edit is quiet and the UI explains itself. */
+static void rotate_layout_ui(app_t *a) {
+ bool one_pane = cur(a)->root && cur(a)->root->kind == NODE_LEAF;
+ if (!app_rotate_layout(a))
+ app_toast(a, one_pane ? "nothing to turn" : "no room to turn it");
+}
+
/* Reordering is a swap of two leaves, in place: their positions, weights and
* parents trade, and everything else about them is untouched. */
static bool swap_panes(app_t *a, uint32_t id_a, uint32_t id_b) {
@@ -2503,6 +2675,12 @@ struct draw {
screen_t *s;
};
+/* Defined with the other shader passes below, because it is one. A collapsed
+ * row needs it here: the row is chrome, and it is the only thing a flattened
+ * tab draws of a pane. */
+static void shade_chrome(app_t *a, screen_t *s, node_t *n, rect_t r,
+ const rect_t *hole);
+
/* A collapsed subtree: one row, the title of the pane it stands for, and a hit
* that focuses it — which expands it on the next layout pass, because the
* expanded child is simply the one holding focus. */
@@ -2519,11 +2697,13 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) {
* gone or was never started.
*
* This row is the only thing a flattened tab draws of a pane, and the
- * shader pass deliberately never reaches it: shaders colour *contents*, and
- * a header is chrome (D13). So the states that get a colour everywhere else
- * have to be carried by the words here, in the same order the status line
- * ranks them. Without this, collapsing a tab hides exactly the facts the
- * colour exists to show. */
+ * *content* shader pass deliberately never reaches it: those colour
+ * contents, and a header is chrome (D13). So the states that get a colour
+ * everywhere else have to be carried by the words here, in the same order
+ * the status line ranks them. Without this, collapsing a tab hides exactly
+ * the facts the colour exists to show. A `where="chrome"` chain does reach
+ * this row — it is a frame — but that is a thing you may have asked for,
+ * not something the words can rely on. */
bool dead = !pane_alive(leaf->pane) && !pane_suspended(leaf->pane);
char words[64];
if (dead) {
@@ -2586,6 +2766,12 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) {
char action[48];
snprintf(action, sizeof action, "focus:%u", leaf->id);
hit_add(&s->hits, r.x, r.y, r.w, 1, action);
+
+ /* Last, so the pass sees the finished row: rule, bell, title and all. The
+ * state comes from the leaf this row stands for, not from the node that
+ * happens to own the rect — a collapsed subtree is drawn as its first pane
+ * and should be coloured as that pane. */
+ shade_chrome(a, s, leaf, r, NULL);
}
/* ---- shaders ------------------------------------------------------------ */
@@ -2626,7 +2812,7 @@ size_t app_shade_count(app_t *a, uint32_t pane_id) {
* should not be recoloured by anything, a mode the whole screen is in outranks
* a hint about one pane, and "not focused" is the weakest thing that can be
* true of a pane. */
-static pane_state_t pane_state(app_t *a, node_t *n) {
+static pane_state_t pane_state_of(app_t *a, node_t *n) {
/* Only once the pointer has actually moved: a press that turns out to be a
* click would otherwise flash the whole session on its way to nothing. */
if (a->drag.kind == DRAG_TITLE && a->drag.moved) {
@@ -2636,11 +2822,31 @@ static pane_state_t pane_state(app_t *a, node_t *n) {
}
if (!pane_alive(n->pane) && !pane_suspended(n->pane)) return PSTATE_DEAD;
if (pane_suspended(n->pane)) return PSTATE_SUSPENDED;
+ /* A rung pane outranks a scrolled or unfocused one, and *replaces* it rather
+ * than stacking with it: states do not stack (D13), and of the things true
+ * of a pane that rang while you were elsewhere, the ringing is the one you
+ * needed telling. The focused pane never reaches here with a bell — looking
+ * at it answers the bell before anything is drawn. */
+ if (pane_bell(n->pane)) return PSTATE_BELL;
if (pane_scrolled(n->pane)) return PSTATE_SCROLLED;
if (n != cur(a)->focus) return PSTATE_UNFOCUSED;
return PSTATE_COUNT;
}
+/* ...and when it last became that, which is the part a frame cannot tell you.
+ * Stamped here, in the one place that decides the state, so `since` cannot end
+ * up describing a state the pane is no longer in. Idempotent within a frame:
+ * the content pass and the chrome pass both ask, and the second sees no
+ * change. */
+static pane_state_t pane_state(app_t *a, node_t *n) {
+ pane_state_t st = pane_state_of(a, n);
+ if (st != n->last_state || !n->state_since) {
+ n->last_state = st;
+ n->state_since = now_ms_();
+ }
+ return st;
+}
+
static size_t policy_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) {
pane_state_t st = pane_state(a, n);
if (st >= PSTATE_COUNT) return 0;
@@ -2650,6 +2856,30 @@ static size_t policy_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) {
return k;
}
+static size_t chrome_policy_shaders(app_t *a, node_t *n, shader_t *out,
+ size_t cap) {
+ pane_state_t st = pane_state(a, n);
+ if (st >= PSTATE_COUNT) return 0;
+ size_t k = CFG.chrome_state_n[st];
+ if (k > cap) k = cap;
+ for (size_t i = 0; i < k; i++) out[i] = CFG.chrome_state_shaders[st][i];
+ return k;
+}
+
+/* Does anything in this chain read the clock? If so the session has to keep
+ * painting on its own, because the thing that will change next is the time.
+ * Asked of the chain that was actually applied rather than of the config: a
+ * pulse hung off `dead` costs a frame clock while a pane is dead and nothing
+ * for the rest of the session. */
+static void note_animation(app_t *a, const shader_t *chain, size_t n) {
+ for (size_t i = 0; i < n; i++)
+ if (chain[i].amount_expr &&
+ (expr_deps(chain[i].amount_expr) & EXPR_DEP_TIME)) {
+ a->animating = true;
+ return;
+ }
+}
+
/* A pane with nothing to apply costs nothing: no context is built and no cell
* is visited, so an unshaded session emits the same bytes it did before. */
static void shade_leaf(app_t *a, screen_t *s, node_t *n) {
@@ -2668,8 +2898,11 @@ static void shade_leaf(app_t *a, screen_t *s, node_t *n) {
if (!nc) return;
bool focused = n == cur(a)->focus;
+ /* After policy_shaders, which is what stamps the transition. */
+ int64_t now = now_ms_();
shade_ctx_t base = {
- .now_ms = now_ms_(),
+ .now_ms = now,
+ .state_ms = n->state_since ? now - n->state_since : 0,
.focused = focused,
.default_fg = CFG.default_fg,
.default_bg = CFG.default_bg,
@@ -2683,8 +2916,46 @@ static void shade_leaf(app_t *a, screen_t *s, node_t *n) {
base.cursor_x = (uint16_t)(s->cursor_x - n->content.x);
base.cursor_y = (uint16_t)(s->cursor_y - n->content.y);
}
- shade_apply(s, chain, nc, n->content.x, n->content.y, n->content.w,
- n->content.h, &base);
+ note_animation(a, chain, nc);
+ shade_apply(s, chain, nc, n->content, NULL, &base);
+}
+
+/* Chrome: a pass over `r`, skipping `hole` (the contents, when the thing being
+ * drawn has any). One pass over the whole frame rather than one per side, so
+ * that an effect can travel round it — four passes would each count from zero
+ * and a sweep would restart at every corner.
+ *
+ * The rect is passed in rather than taken from the node because the two things
+ * that wear chrome are not the same shape: an open pane is its rect minus its
+ * contents, and a collapsed pane is one row that is chrome all the way through
+ * — its `content` is the size the program inside still believes it has, which
+ * is somewhere else entirely, and punching that out of the row would remove
+ * cells the row does not even overlap. `n` is only asked which state it is in,
+ * so a collapsed row is coloured by the pane it stands for.
+ *
+ * No cursor. The cursor is in the contents by construction, so a frame pass
+ * carrying one would hand an effect a position outside its own rect; `cursor`
+ * reads 0 here, which is the truth about a frame. */
+static void shade_chrome(app_t *a, screen_t *s, node_t *n, rect_t r,
+ const rect_t *hole) {
+ shader_t chain[SHADE_CHAIN_MAX];
+ size_t nc = 0;
+
+ for (size_t i = 0; i < CFG.nchrome_shaders && nc < SHADE_CHAIN_MAX; i++)
+ chain[nc++] = CFG.chrome_shaders[i];
+ nc += chrome_policy_shaders(a, n, &chain[nc], SHADE_CHAIN_MAX - nc);
+ if (!nc) return;
+
+ int64_t now = now_ms_();
+ shade_ctx_t base = {
+ .now_ms = now,
+ .state_ms = n->state_since ? now - n->state_since : 0,
+ .focused = n == cur(a)->focus,
+ .default_fg = CFG.default_fg,
+ .default_bg = CFG.default_bg,
+ };
+ note_animation(a, chain, nc);
+ shade_apply(s, chain, nc, r, hole, &base);
}
/* Drawn after the panes, so the corner's hit is registered after the two gap
@@ -2761,8 +3032,15 @@ static void draw_cb(node_t *n, void *ud) {
}
/* Between the contents and the chrome that goes over them: the frame was
* painted before this and lies outside the content rect, and the split guide
- * is painted after, so it stays legible on top of a shaded pane. */
+ * is painted after, so it stays legible on top of a shaded pane.
+ *
+ * The chrome pass runs here too, and for the same reason: the frame it
+ * recolours is already painted, and the guide, the resize hints and the
+ * corners are painted after it and stay in their own colours. An affordance
+ * is not decoration and must not be dimmed along with the thing it is
+ * offered on. */
shade_leaf(d->a, d->s, n);
+ shade_chrome(d->a, d->s, n, n->rect, &n->content);
draw_split_guide(d->a, d->s, n);
}
@@ -3287,7 +3565,7 @@ static void draw_scrim(app_t *a, screen_t *s) {
.default_fg = CFG.default_fg,
.default_bg = CFG.default_bg,
};
- shade_apply(s, &dim, 1, 0, 0, s->cols, s->rows, &base);
+ shade_apply(s, &dim, 1, (rect_t){0, 0, s->cols, s->rows}, NULL, &base);
}
/* The frame every modal wears: an opaque box with a pane's corners, a title
@@ -3909,6 +4187,8 @@ void app_compose(app_t *a, screen_t *s) {
hit_reset(&s->hits);
s->cursor_visible = false;
a->painted = s;
+ /* Re-derived by the shader passes below, every frame, from what they ran. */
+ a->animating = false;
if (!a->ntabs || !cur(a)->root) return;
/* Looking at a pane is the acknowledgement, and it happens before anything
@@ -4385,6 +4665,10 @@ static bool run_action(app_t *a, action_t act) {
case ACT_RESIZE_RIGHT: resize_focus(a, 1, 0); return true;
case ACT_RESIZE_UP: resize_focus(a, 0, -1); return true;
case ACT_RESIZE_DOWN: resize_focus(a, 0, 1); return true;
+ case ACT_EQUALIZE:
+ if (!app_equalize_splits(a)) app_toast(a, "nothing to even out");
+ return true;
+ case ACT_ROTATE_LAYOUT: rotate_layout_ui(a); return true;
case ACT_SCROLL_UP: pane_scroll(cur(a)->focus->pane, -CFG.scroll_lines); return true;
case ACT_SCROLL_DOWN: pane_scroll(cur(a)->focus->pane, CFG.scroll_lines); return true;
case ACT_SCROLL_PAGE_UP:
diff --git a/src/app.h b/src/app.h
index 0455732..e9740ed 100644
--- a/src/app.h
+++ b/src/app.h
@@ -8,10 +8,6 @@
#include "shader.h"
#include "sl0ppty.h"
-typedef struct {
- uint16_t x, y, w, h;
-} rect_t;
-
typedef enum { SPLIT_COLS, SPLIT_ROWS } split_dir_t;
typedef struct node node_t;
@@ -30,8 +26,10 @@ char *app_graphics_json(app_t *a);
/* Transient announcements, drawn bottom-right and expiring on their own. */
void app_toast(app_t *a, const char *text);
size_t app_toast_count(app_t *a);
-/* Milliseconds until something needs repainting on its own (a toast expiring),
- * or -1 when nothing does. */
+/* Milliseconds until something needs repainting on its own — a toast expiring,
+ * a hover guide arming, or a shader whose amount reads the clock having run
+ * over the frame just composed — or -1 when nothing does. A property of that
+ * frame, so it is asked after composing one. */
int app_next_deadline_ms(app_t *a);
/* Text the session has copied, and the copy the client has not been told
@@ -107,6 +105,12 @@ bool app_minimize(app_t *a, uint32_t id);
bool app_toggle_zoom(app_t *a, uint32_t id);
bool app_pane_zoomed(app_t *a, uint32_t id);
+/* Give every visible pane in the current tab an even share of the rows and
+ * columns it competes for: each split's children weighted by how many visible
+ * panes are behind them, so "even" means the same thing at every depth. False
+ * when the tab is a single pane and there is nothing to divide. */
+bool app_equalize_splits(app_t *a);
+
/* Address panes by id (the control API), not "the focused one". */
bool app_focus_pane(app_t *a, uint32_t id);
bool app_split_pane(app_t *a, uint32_t id, bool rows);
@@ -114,6 +118,12 @@ bool app_close_pane(app_t *a, uint32_t id);
/* Run a dead (or not-yet-started) pane's command again, in the same pane and
* on top of the same scrollback. 0 means the focused one. */
bool app_rerun_pane(app_t *a, uint32_t id);
+
+/* Turn the current tab's layout a quarter turn clockwise: every split changes
+ * axis, and the children of a row split reverse, because the pane on top of a
+ * stack is the pane on the right once you turn it. Four turns are the identity.
+ * False when the tab is a single pane, which looks the same from every angle. */
+bool app_rotate_layout(app_t *a);
/* Open the config file in $EDITOR, in a pane of its own, so the edit/save/
* watch loop happens without leaving the session. The pane is ephemeral. */
bool app_edit_config(app_t *a);
diff --git a/src/cmd.c b/src/cmd.c
index e08b4f2..758586b 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -110,6 +110,14 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
}
return jok_raw("screen", screen_dump_json(s));
}
+ if (strcmp(cmd, "deadline") == 0) {
+ /* When this session wants its next frame, in milliseconds, or -1 for "only
+ * when something happens". A property of the frame last composed -- an
+ * animated shader asks for a clock, a toast asks to expire -- so a caller
+ * that has not composed one is asking about nothing. The server's poll loop
+ * lives on this answer; a scripted front end has the same question. */
+ return jok_int("ms", app_next_deadline_ms(a));
+ }
if (strcmp(cmd, "send") == 0) {
const char *data = jv_gets(req, "data", "");
input_feed(in, (const uint8_t *)data, strlen(data), feed_event, a);
@@ -334,6 +342,11 @@ char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
app_compose(a, s);
return strcmp(arg, "text") == 0 ? screen_dump(s) : screen_dump_json(s);
}
+ if (strcmp(verb, "deadline") == 0) {
+ char buf[24];
+ snprintf(buf, sizeof buf, "%d", app_next_deadline_ms(a));
+ return strdup(buf);
+ }
if (strcmp(verb, "panes") == 0) {
app_compose(a, s); /* layout is a function of the frame: compose first */
return app_panes_json(a);
diff --git a/src/config.c b/src/config.c
index cac6b72..3a27152 100644
--- a/src/config.c
+++ b/src/config.c
@@ -23,6 +23,8 @@ static const struct {
{"focus-down", ACT_FOCUS_DOWN}, {"focus-next", ACT_FOCUS_NEXT},
{"resize-left", ACT_RESIZE_LEFT}, {"resize-right", ACT_RESIZE_RIGHT},
{"resize-up", ACT_RESIZE_UP}, {"resize-down", ACT_RESIZE_DOWN},
+ {"equalize", ACT_EQUALIZE},
+ {"rotate-layout", ACT_ROTATE_LAYOUT},
{"scroll-up", ACT_SCROLL_UP}, {"scroll-down", ACT_SCROLL_DOWN},
{"scroll-page-up", ACT_SCROLL_PAGE_UP},
{"scroll-page-down", ACT_SCROLL_PAGE_DOWN},
@@ -115,22 +117,61 @@ static bool parse_color(const char *text, color_t *out) {
static const char *const PSTATE_NAMES[PSTATE_COUNT] = {
"dragging", "drop_hover", "drop_target", "dead", "suspended",
- "scrolled", "unfocused",
+ "bell", "scrolled", "unfocused",
};
const char *pane_state_name(pane_state_t s) {
return s < PSTATE_COUNT ? PSTATE_NAMES[s] : "";
}
-/* Reads the children of `node` as a shader chain into `out` (SHADE_MAX slots),
- * returning how many were understood. */
-static size_t parse_shader_list(config_t *c, const kdl_node_t *node,
- shader_t *out, char *err, size_t errcap) {
- size_t n = 0;
- for (size_t i = 0; i < node->nkids && n < SHADE_MAX; i++) {
+/* Reads the children of `node` as shader chains: the ones that run over a
+ * pane's contents and the ones that run over its frame, routed by each entry's
+ * `where` (default "content"). One parser, because a chrome pass is not a
+ * different kind of shader — it is the same chain run over a different rect,
+ * and a second parser would be a second place for `amount` to mean something
+ * slightly else. Counts are set, not added to: naming a block replaces it. */
+static void parse_shader_list(config_t *c, const kdl_node_t *node,
+ shader_t *content, size_t *ncontent,
+ shader_t *chrome, size_t *nchrome, char *err,
+ size_t errcap) {
+ *ncontent = 0;
+ *nchrome = 0;
+ for (size_t i = 0; i < node->nkids; i++) {
const kdl_node_t *k = node->kids[i];
if (!k || !k->name) continue;
+ /* Where this pass runs. Dropped rather than defaulted when the word is not
+ * one we know, for the same reason a bad `amount` is: the entry says what
+ * it wants and we cannot do it, and running it over the contents because
+ * "chrom" was a typo would be a surprise nobody asked for. */
+ const char *where = kdl_prop(k, "where", "content");
+ bool on_chrome = strcmp(where, "chrome") == 0;
+ if (!on_chrome && strcmp(where, "content") != 0) {
+ if (err && !err[0])
+ snprintf(err, errcap, "bad where for %s: %s (content or chrome)",
+ k->name, where);
+ continue;
+ }
+ shader_t *out = on_chrome ? chrome : content;
+ size_t *n = on_chrome ? nchrome : ncontent;
+ if (*n >= SHADE_MAX) continue;
+
+ /* Which of the cell's two colours it may touch. `fg` is what a *border*
+ * flash wants: a frame's background is usually the terminal's own default,
+ * and mixing that towards a colour turns a recoloured glyph into a painted
+ * rectangle. Refused the same way a bad `where` is, and for the same
+ * reason. */
+ const char *chan = kdl_prop(k, "channel", "both");
+ uint8_t channels = SHADE_BOTH;
+ if (strcmp(chan, "fg") == 0) channels = SHADE_FG;
+ else if (strcmp(chan, "bg") == 0) channels = SHADE_BG;
+ else if (strcmp(chan, "both") != 0) {
+ if (err && !err[0])
+ snprintf(err, errcap, "bad channel for %s: %s (fg, bg or both)",
+ k->name, chan);
+ continue;
+ }
+
/* `amount` is a number, or an expression that produces one per cell.
* Same key either way: `amount=90` and `amount="(y % 2) * 40"` are the
* same idea, one of them constant, and the compiler folds a constant
@@ -174,20 +215,20 @@ static size_t parse_shader_list(config_t *c, const kdl_node_t *node,
if (cs && !parse_color(cs, &col) && err && !err[0])
snprintf(err, errcap, "bad colour for shader %s: %s", k->name, cs);
- if (!shader_make_p(&out[n], k->name, col, (uint8_t)amount,
- (uint16_t)param)) {
+ shader_t *slot = &out[*n];
+ if (!shader_make_p(slot, k->name, col, (uint8_t)amount, (uint16_t)param)) {
if (err && !err[0]) snprintf(err, errcap, "unknown shader: %s", k->name);
expr_free(aexpr);
continue;
}
- out[n].amount_expr = aexpr;
+ slot->channels = channels;
+ slot->amount_expr = aexpr;
if (aexpr) {
c->exprs = realloc(c->exprs, (c->nexprs + 1) * sizeof *c->exprs);
c->exprs[c->nexprs++] = aexpr; /* the config owns every program */
}
- n++;
+ (*n)++;
}
- return n;
}
/* `direct` is part of the identity, not a property of it: `x` after the leader
@@ -326,6 +367,10 @@ void config_defaults(config_t *c) {
c->toast_ms = 2500;
c->hover_delay_ms = 250;
c->double_click_ms = 400;
+ /* 20fps. Fast enough that a pulse or a sweep reads as movement, slow enough
+ * that an idle session with an animated shader is not a busy loop -- and it
+ * only costs anything at all while such a shader is actually on screen. */
+ c->anim_ms = 50;
c->modal_scrim = 120;
c->status_bar = true;
c->status_line = true;
@@ -491,11 +536,22 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, MOD_SHIFT, ACT_RESIZE_RIGHT, false);
bind_add(c, GHOSTTY_KEY_ARROW_UP, MOD_SHIFT, ACT_RESIZE_UP, false);
bind_add(c, GHOSTTY_KEY_ARROW_DOWN, MOD_SHIFT, ACT_RESIZE_DOWN, false);
+ bind_add(c, GHOSTTY_KEY_EQUAL, 0, ACT_EQUALIZE, false);
+ /* The leader and the space bar: the biggest key on the keyboard, no modifier,
+ * and the one tmux already spends on cycling layouts — so the hand that knows
+ * that reaches for the right thing here. Four presses come back round, which
+ * is what makes a key this easy to hit the right choice rather than a hazard. */
+ bind_add(c, GHOSTTY_KEY_SPACE, 0, ACT_ROTATE_LAYOUT, false);
bind_add(c, GHOSTTY_KEY_C, 0, ACT_NEW_TAB, false);
- bind_add(c, GHOSTTY_KEY_N, 0, ACT_NEXT_TAB, false);
- bind_add(c, GHOSTTY_KEY_P, 0, ACT_PREV_TAB, false);
+ /* Cycling tabs is on tab/shift+tab, not on n/p: `p` is the palette, which is
+ * pressed far more often than "the tab before this one" and had the only
+ * shifted letter in the defaults. Tab is the key every other tabbed thing
+ * cycles with, and it decodes on a plain terminal (`\e[Z` is shift+tab), so
+ * this costs nothing on a client without the kitty protocol. */
+ bind_add(c, GHOSTTY_KEY_TAB, 0, ACT_NEXT_TAB, false);
+ bind_add(c, GHOSTTY_KEY_TAB, MOD_SHIFT, ACT_PREV_TAB, false);
bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER, false);
- bind_add(c, GHOSTTY_KEY_P, MOD_SHIFT, ACT_PALETTE, false);
+ bind_add(c, GHOSTTY_KEY_P, 0, ACT_PALETTE, false);
bind_add(c, GHOSTTY_KEY_PAGE_UP, 0, ACT_SCROLL_PAGE_UP, false);
bind_add(c, GHOSTTY_KEY_PAGE_DOWN, 0, ACT_SCROLL_PAGE_DOWN, false);
bind_add(c, GHOSTTY_KEY_HOME, 0, ACT_SCROLL_TOP, false);
@@ -546,6 +602,10 @@ static const struct {
{ACT_RESIZE_RIGHT, "size", "move the boundary right"},
{ACT_RESIZE_UP, "size", "move the boundary up"},
{ACT_RESIZE_DOWN, "size", "move the boundary down"},
+ /* Short enough to fit the palette's label column, which truncates at 26 —
+ * "give every pane an even share" read as "give every pane an even sh". */
+ {ACT_EQUALIZE, "size", "even out every split"},
+ {ACT_ROTATE_LAYOUT, "size", "turn the layout a quarter"},
{ACT_NEW_TAB, "tabs", "new tab"},
{ACT_NEXT_TAB, "tabs", "next tab"},
@@ -710,7 +770,7 @@ static void cb_color(cfgbuf_t *b, const char *name, color_t c) {
}
static void cb_chain(cfgbuf_t *b, const char *indent, const shader_t *sh,
- size_t n) {
+ size_t n, bool chrome) {
for (size_t i = 0; i < n; i++) {
if (!sh[i].kind) continue;
cb_add(b, "%s%s amount=%u", indent, sh[i].kind, sh[i].amount);
@@ -718,6 +778,9 @@ static void cb_chain(cfgbuf_t *b, const char *indent, const shader_t *sh,
cb_add(b, " color=\"#%02x%02x%02x\"", sh[i].color.r, sh[i].color.g,
sh[i].color.b);
if (sh[i].param) cb_add(b, " at=%u", sh[i].param);
+ if (chrome) cb_add(b, " where=\"chrome\"");
+ if (sh[i].channels == SHADE_FG) cb_add(b, " channel=\"fg\"");
+ else if (sh[i].channels == SHADE_BG) cb_add(b, " channel=\"bg\"");
cb_add(b, "\n");
}
}
@@ -776,6 +839,8 @@ char *config_render(const config_t *c) {
cb_add(&b, "toast_ms %u\n", c->toast_ms);
cb_add(&b, "hover_delay_ms %u\n", c->hover_delay_ms);
cb_add(&b, "double_click_ms %u\n", c->double_click_ms);
+ cb_add(&b, "anim_ms %u // frame clock while a shader animates\n",
+ c->anim_ms);
cb_add(&b, "modal_scrim %u // how far a modal pushes the rest back\n",
c->modal_scrim);
cb_add(&b, "dim_unfocused %u // ...and how far the panes you are not in\n",
@@ -796,17 +861,22 @@ char *config_render(const config_t *c) {
cb_add(&b, "}\n");
/* Written even when empty: an empty block says "this exists and you have
- * none", where nothing at all says "we forgot to tell you". */
- cb_add(&b, "\n// ---- colour passes over every pane's contents ----\n");
+ * none", where nothing at all says "we forgot to tell you". Chrome passes
+ * live in the same blocks, marked, because that is how they are written. */
+ cb_add(&b, "\n// ---- colour passes over every pane ----\n");
cb_add(&b, "// (contrib/shaders has thirty-odd to paste; contrib/shadertoy.html\n");
- cb_add(&b, "// previews them)\nshaders {\n");
- cb_chain(&b, " ", c->shaders, c->nshaders);
+ cb_add(&b, "// previews them. where=\"chrome\" runs a pass over the frame\n");
+ cb_add(&b, "// instead of the contents)\nshaders {\n");
+ cb_chain(&b, " ", c->shaders, c->nshaders, false);
+ cb_chain(&b, " ", c->chrome_shaders, c->nchrome_shaders, true);
cb_add(&b, "}\n");
cb_add(&b, "\n// ---- what a pane looks like in a given state ----\nstates {\n");
for (int st = 0; st < PSTATE_COUNT; st++) {
cb_add(&b, " %s {\n", pane_state_name((pane_state_t)st));
- cb_chain(&b, " ", c->state_shaders[st], c->state_n[st]);
+ cb_chain(&b, " ", c->state_shaders[st], c->state_n[st], false);
+ cb_chain(&b, " ", c->chrome_state_shaders[st],
+ c->chrome_state_n[st], true);
cb_add(&b, " }\n");
}
cb_add(&b, "}\n");
@@ -994,6 +1064,8 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
c->hover_delay_ms);
c->double_click_ms = (uint16_t)kdl_arg_int(kdl_child(root, "double_click_ms"),
0, c->double_click_ms);
+ c->anim_ms =
+ (uint16_t)kdl_arg_int(kdl_child(root, "anim_ms"), 0, c->anim_ms);
{
long v = kdl_arg_int(kdl_child(root, "modal_scrim"), 0, c->modal_scrim);
c->modal_scrim = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v);
@@ -1044,7 +1116,9 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
* Shared by `shaders { }` and by every state, so a state's chain is exactly
* as expressive as the global one and there is one parser to be wrong. */
const kdl_node_t *shaders = kdl_child(root, "shaders");
- if (shaders) c->nshaders = parse_shader_list(c, shaders, c->shaders, err, errcap);
+ if (shaders)
+ parse_shader_list(c, shaders, c->shaders, &c->nshaders, c->chrome_shaders,
+ &c->nchrome_shaders, err, errcap);
/* `states { drop_target { grayscale amount=200; dim amount=140 } }` — what a
* pane looks like while it is in a state. Naming a state at all replaces its
@@ -1064,7 +1138,9 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
continue;
}
if (st == PSTATE_UNFOCUSED) unfocused_declared = true;
- c->state_n[st] = parse_shader_list(c, k, c->state_shaders[st], err, errcap);
+ parse_shader_list(c, k, c->state_shaders[st], &c->state_n[st],
+ c->chrome_state_shaders[st], &c->chrome_state_n[st],
+ err, errcap);
}
}
/* After the states block, so a config that wrote its own chain keeps it and
diff --git a/src/config.h b/src/config.h
index 73ecc7e..7d9e50a 100644
--- a/src/config.h
+++ b/src/config.h
@@ -26,6 +26,12 @@ typedef enum {
PSTATE_DROP_TARGET, /* the others, all of them somewhere it could go */
PSTATE_DEAD, /* its program exited; waiting to be re-run or closed */
PSTATE_SUSPENDED, /* laid out, never started */
+ /* A BEL arrived and nobody has looked since. Above the ambient states
+ * because it is *news* — the whole point of a bell is to be noticed on a
+ * pane you were not watching — and below `dead`, because a pane that rang
+ * and then exited is a pane that exited. It ends when you look at the pane,
+ * which is what answering a bell is. */
+ PSTATE_BELL,
PSTATE_SCROLLED, /* looking at scrollback rather than the present */
PSTATE_UNFOCUSED,
PSTATE_COUNT,
@@ -42,6 +48,7 @@ typedef enum {
ACT_RERUN,
ACT_ZOOM,
ACT_MINIMIZE,
+ ACT_ROTATE_LAYOUT,
ACT_FOCUS_LEFT,
ACT_FOCUS_RIGHT,
ACT_FOCUS_UP,
@@ -62,6 +69,7 @@ typedef enum {
ACT_RESIZE_RIGHT,
ACT_RESIZE_UP,
ACT_RESIZE_DOWN,
+ ACT_EQUALIZE,
ACT_DETACH,
ACT_QUIT,
ACT_HELP,
@@ -184,6 +192,24 @@ typedef struct {
* already had to avoid by hand. */
shader_t state_shaders[PSTATE_COUNT][SHADE_MAX];
size_t state_n[PSTATE_COUNT];
+
+ /* The same two things again, over a pane's *frame* rather than its contents:
+ * any entry in `shaders` or in a state's block carrying `where="chrome"`
+ * lands here instead. Separate chains rather than a flag on each shader,
+ * because a pass is per rect: the contents and the frame are two rects and
+ * therefore two passes, and a chain is exactly the list one pass runs. It
+ * also keeps the flag out of the plugin ABI, which has no business knowing
+ * where a session chose to run an effect. */
+ shader_t chrome_shaders[SHADE_MAX];
+ size_t nchrome_shaders;
+ shader_t chrome_state_shaders[PSTATE_COUNT][SHADE_MAX];
+ size_t chrome_state_n[PSTATE_COUNT];
+ /* How often to repaint while something on screen is animated -- which means
+ * a shader whose amount reads the clock. Nothing else needs a frame clock:
+ * every other repaint has an event behind it. 0 turns animation off, in the
+ * sense that an animated shader then only advances when something else
+ * causes a frame. */
+ uint16_t anim_ms;
bool status_bar; /* the strip along the top: tabs, prefix, pane count */
bool status_line; /* the line along the bottom: what you are looking at */
/* How far the strip and the line are held off the left and right edges.
diff --git a/src/expr.c b/src/expr.c
index 3400e34..677b3b5 100644
--- a/src/expr.c
+++ b/src/expr.c
@@ -19,7 +19,8 @@ enum {
};
/* Variable slots, in the order the names table lists them. */
-enum { V_X, V_Y, V_COLS, V_ROWS, V_CURX, V_CURY, V_CURSOR, V_FOCUSED, V_T };
+enum { V_X, V_Y, V_COLS, V_ROWS, V_CURX, V_CURY, V_CURSOR, V_FOCUSED, V_T,
+ V_SINCE };
static const struct {
const char *name;
@@ -35,6 +36,9 @@ static const struct {
{"cursor", V_CURSOR, EXPR_DEP_CURSOR},
{"focused", V_FOCUSED, EXPR_DEP_FOCUS},
{"t", V_T, EXPR_DEP_TIME},
+ /* Also a clock, so it carries the same dependency: never cached, and the
+ * session keeps painting while something reads it. */
+ {"since", V_SINCE, EXPR_DEP_TIME},
};
/* cols/rows are a dependency even though the map's key always carries the
@@ -314,6 +318,12 @@ static int vm_run(const expr_prog_t *pr, const expr_env_t *env) {
[V_CURX] = env->curx, [V_CURY] = env->cury,
[V_CURSOR] = env->cursor, [V_FOCUSED] = env->focused,
[V_T] = (int32_t)(env->t & 0x7fffffff),
+ /* Clamped, not wrapped: a pane that has been unfocused for a month
+ * should read as "a long time", and a `since` that rolled over would
+ * make an effect restart for no reason anybody could see. */
+ [V_SINCE] = (int32_t)(env->since < 0 ? 0
+ : env->since > 0x7fffffff ? 0x7fffffff
+ : env->since),
};
for (size_t i = 0; i < pr->n; i++) {
diff --git a/src/expr.h b/src/expr.h
index 075df7d..6508ec7 100644
--- a/src/expr.h
+++ b/src/expr.h
@@ -20,15 +20,18 @@
* nothing in here has an effect. Evaluation is therefore O(program length),
* a bad expression cannot hang a session, and there is nothing to sandbox.
*
- * variables x y cols rows curx cury cursor focused t
+ * variables x y cols rows curx cury cursor focused t since
* operators + - * / % < > <= >= == != && || ! ?:
* functions min max abs clamp dist
*
- * `x`/`y` are cell positions inside the pane's content rect, `t` is a
- * millisecond clock for animation, `dist` corrects for a cell being about
- * twice as tall as it is wide. Division and modulo by zero are 0 rather than
- * a trap: an expression that is wrong should look wrong, not take the session
- * with it.
+ * `x`/`y` are cell positions inside the rect the pass runs over (a pane's
+ * contents, or its frame for a chrome pass), `t` is a millisecond clock for
+ * animation, `since` is how many milliseconds the pane has been in the state
+ * it is in — which is what a one-shot effect needs, because `t` says what time
+ * it is and not how long ago something happened. `dist` corrects for a cell
+ * being about twice as tall as it is wide. Division and modulo by zero are 0
+ * rather than a trap: an expression that is wrong should look wrong, not take
+ * the session with it.
*/
#ifndef SL0PPTY_EXPR_H
#define SL0PPTY_EXPR_H
@@ -71,6 +74,8 @@ typedef struct {
int curx, cury, cursor;
int focused;
int64_t t;
+ /* Milliseconds since the pane entered its current state, clamped at 0. */
+ int64_t since;
} expr_env_t;
/* Evaluate for one cell. Clamped to 0..255 by the caller, not here: the
diff --git a/src/shader.c b/src/shader.c
index b1bb1a1..ba2888a 100644
--- a/src/shader.c
+++ b/src/shader.c
@@ -186,7 +186,10 @@ bool shader_make_p(shader_t *out, const char *kind, color_t color,
if (!out || !kind) return false;
const shader_def_t *d = find_kind(kind);
if (!d) return false;
- *out = (shader_t){d->name, d->fn, NULL, color, amount, param};
+ /* channels left 0, which the pass reads as both: a shader made here has no
+ * opinion about that, and the config sets it when the config has one. */
+ *out = (shader_t){.kind = d->name, .fn = d->fn, .color = color,
+ .amount = amount, .param = param};
return true;
}
@@ -318,9 +321,9 @@ size_t shader_load_dir(const char *dir, char *err, size_t errcap) {
/* ---- the pass ----------------------------------------------------------- */
-void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
- uint16_t y0, uint16_t w, uint16_t h, const shade_ctx_t *base) {
- if (!s || !shaders || !n || !w || !h) return;
+void shade_apply(screen_t *s, const shader_t *shaders, size_t n, rect_t r,
+ const rect_t *hole, const shade_ctx_t *base) {
+ if (!s || !shaders || !n || !r.w || !r.h) return;
/* Clip once, here, so the inner loop can walk a row pointer instead of
* bounds-checking every cell through screen_at(). That call was a fifth of
@@ -328,11 +331,25 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
* already guarantees, once per cell per shader. The layout never asks for a
* rect that needs clipping; this is so that a caller that does gets a
* smaller rect rather than an out-of-bounds write. */
+ uint16_t x0 = r.x, y0 = r.y, w = r.w, h = r.h;
if (x0 >= s->cols || y0 >= s->rows) return;
if ((size_t)x0 + w > s->cols) w = (uint16_t)(s->cols - x0);
if ((size_t)y0 + h > s->rows) h = (uint16_t)(s->rows - y0);
if (!w || !h) return;
+ /* The hole in this rect's own coordinates, as a half-open span per axis.
+ * Computed once: the inner loop then asks two comparisons per cell, and an
+ * empty hole leaves an empty span that nothing is ever inside. */
+ uint16_t hx0 = 0, hx1 = 0, hy0 = 0, hy1 = 0;
+ if (hole && hole->w && hole->h && hole->x + hole->w > x0 &&
+ hole->y + hole->h > y0) {
+ hx0 = (uint16_t)(hole->x > x0 ? hole->x - x0 : 0);
+ hy0 = (uint16_t)(hole->y > y0 ? hole->y - y0 : 0);
+ long ex = (long)hole->x + hole->w - x0, ey = (long)hole->y + hole->h - y0;
+ hx1 = (uint16_t)(ex > w ? w : ex);
+ hy1 = (uint16_t)(ey > h ? h : ey);
+ }
+
shade_ctx_t ctx = base ? *base : (shade_ctx_t){0};
ctx.cols = w;
ctx.rows = h;
@@ -353,6 +370,11 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
const uint8_t *map = NULL;
expr_env_t env = {0};
bool per_cell = false;
+ /* Which colours this pass keeps. Per shader, not per cell: it is one test
+ * of a byte, and the inner loop is walked tens of thousands of times a
+ * frame. 0 means both, so a zeroed shader_t behaves as it always did. */
+ bool keep_fg = !sh->channels || (sh->channels & SHADE_FG);
+ bool keep_bg = !sh->channels || (sh->channels & SHADE_BG);
if (sh->amount_expr) {
env.cols = w;
@@ -362,6 +384,7 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
env.cursor = ctx.has_cursor;
env.focused = ctx.focused;
env.t = ctx.now_ms;
+ env.since = ctx.state_ms;
if (expr_deps(sh->amount_expr) == 0) {
local.amount = (uint8_t)clamp255(expr_constant(sh->amount_expr));
} else {
@@ -379,12 +402,27 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
for (uint16_t y = 0; y < h; y++) {
cell_t *row = &s->cur[(size_t)(y0 + y) * s->cols + x0];
ctx.y = y;
+ bool y_in_hole = y >= hy0 && y < hy1;
for (uint16_t x = 0; x < w; x++) {
+ /* Inside the hole: not this pass's cell. Skipped before anything is
+ * materialised on it, so a frame pass leaves the contents exactly as
+ * the content pass left them. */
+ if (y_in_hole && x >= hx0 && x < hx1) {
+ x = (uint16_t)(hx1 - 1); /* jump the span rather than walk it */
+ continue;
+ }
cell_t *c = &row[x];
/* width 0 is the tail half of a wide cell: never painted, so shading
* it would be computing a colour nothing can display. */
if (!c->width) continue;
+ /* What the cell was, for whichever colour this pass does not keep.
+ * Taken before materialising, so a colour the terminal was drawing in
+ * its own default goes back to *unset* rather than to our idea of it —
+ * the difference between recolouring a glyph and painting a rectangle
+ * behind it. */
+ color_t was_fg = c->fg, was_bg = c->bg;
+
/* Materialised per shader rather than once for the chain, so a shader
* added later cannot observe a half-resolved cell. Idempotent: once
* set, this does nothing. */
@@ -400,6 +438,9 @@ void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
local.amount = (uint8_t)clamp255(expr_eval(sh->amount_expr, &env));
}
local.fn(&local, &ctx, c);
+
+ if (!keep_fg) c->fg = was_fg;
+ if (!keep_bg) c->bg = was_bg;
}
}
}
diff --git a/src/shader.h b/src/shader.h
index 5e25b37..8cee437 100644
--- a/src/shader.h
+++ b/src/shader.h
@@ -68,9 +68,16 @@ const char *shader_kind(size_t i);
* anywhere: the trust decision is "put a file here". */
size_t shader_load_dir(const char *dir, char *err, size_t errcap);
-/* Run `n` shaders in order over the w×h rect at (x0,y0). `base` supplies
- * everything in the context except the per-cell position and the size. */
-void shade_apply(screen_t *s, const shader_t *shaders, size_t n, uint16_t x0,
- uint16_t y0, uint16_t w, uint16_t h, const shade_ctx_t *base);
+/* Run `n` shaders in order over `r`, skipping the cells inside `hole` (NULL
+ * for none). `base` supplies everything in the context except the per-cell
+ * position and the size.
+ *
+ * The hole is what makes a pane's *frame* a rect: chrome is the pane's rect
+ * minus its contents, and an effect has to see the whole frame's coordinates
+ * or it could not travel round one — four separate passes over the four sides
+ * would each start counting from zero. Skipped cells are not visited at all,
+ * so a chrome pass cannot touch a cell the content pass owns. */
+void shade_apply(screen_t *s, const shader_t *shaders, size_t n, rect_t r,
+ const rect_t *hole, const shade_ctx_t *base);
#endif /* SL0PPTY_SHADER_H */
diff --git a/src/shader_abi.h b/src/shader_abi.h
index 60fbbc5..dbd840d 100644
--- a/src/shader_abi.h
+++ b/src/shader_abi.h
@@ -18,7 +18,24 @@
#include <stddef.h>
#include <stdint.h>
-#define SL0PPTY_SHADER_ABI 2
+/* 3: shade_ctx_t gained `state_ms`, so an effect can know how long the pane has
+ * been as it is and not only what time it is; struct shader gained
+ * `channels`, which is the pass's business rather than a shader's. */
+#define SL0PPTY_SHADER_ABI 3
+
+/* Which of a cell's two colours a pass is allowed to keep. Enforced by the
+ * pass, not by the shader: every shader writes whatever it writes, and the
+ * cell's other colour is put back afterwards — so this works for a built-in
+ * and for a loaded one, and no shader has to grow a variant of itself.
+ *
+ * Put back to what it *was*, which for a cell the terminal is drawing in its
+ * own default colour means back to unset. That is the difference between
+ * recolouring a border glyph and painting a dark rectangle behind it. */
+enum {
+ SHADE_FG = 1 << 0,
+ SHADE_BG = 1 << 1,
+ SHADE_BOTH = SHADE_FG | SHADE_BG, /* and 0 means this, so zeroed is normal */
+};
enum {
ATTR_BOLD = 1 << 0,
@@ -57,6 +74,12 @@ typedef struct {
uint16_t x, y; /* cell position within the content rect, 0-based */
uint16_t cols, rows; /* content size, so an effect can be positional */
int64_t now_ms; /* for anything animated */
+ /* How long this pane has been in the state it is in, in milliseconds — the
+ * bell that just rang, the pane that died a moment ago, the one you have not
+ * been in for a while. `now_ms` says what time it is; a one-shot effect needs
+ * to know how long ago something happened, and only the session can say. 0
+ * when there is nothing to date the state from. */
+ int64_t state_ms;
bool focused;
/* Where the cursor is, in the same rect-relative space as x/y. Only ever
@@ -97,6 +120,10 @@ struct shader {
* gradient 0 down, 1 up, 2 right, 3 left
*/
uint16_t param;
+ /* SHADE_FG / SHADE_BG / SHADE_BOTH, and 0 for both. Opaque to a shader, like
+ * `amount_expr` above: it is the pass that keeps a channel, so a shader
+ * cannot get it wrong and does not have to know. */
+ uint8_t channels;
};
/* ---- plugins ------------------------------------------------------------
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index cb916c5..319fcba 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -12,6 +12,13 @@
* the one place they are a *contract* with code compiled outside this tree. */
#include "shader_abi.h"
+/* A rectangle of cells. Here rather than in app.h because the layout is not
+ * the only thing that has one: a shader pass is given a rect to run over, and
+ * the frame pass is given the rect it must skip. */
+typedef struct {
+ uint16_t x, y, w, h;
+} rect_t;
+
/* ---- screen: our composited cell buffer + diff emitter ---------------- */
/* One geometry (DESIGN.md): every painted interactive element records its rect