diff options
| author | user <user@clank> | 2026-08-15 23:06:32 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 23:06:32 +0200 |
| commit | f6281fbec93d92bfc7e9164492e436d0236d9179 (patch) | |
| tree | c78b435dec7f62c948e2e8144f29f47ccab2fa7d /src | |
| parent | sl0ppty: a command palette, on a key you choose (diff) | |
| download | slosh-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.c | 314 | ||||
| -rw-r--r-- | src/app.h | 22 | ||||
| -rw-r--r-- | src/cmd.c | 13 | ||||
| -rw-r--r-- | src/config.c | 122 | ||||
| -rw-r--r-- | src/config.h | 26 | ||||
| -rw-r--r-- | src/expr.c | 12 | ||||
| -rw-r--r-- | src/expr.h | 17 | ||||
| -rw-r--r-- | src/shader.c | 49 | ||||
| -rw-r--r-- | src/shader.h | 15 | ||||
| -rw-r--r-- | src/shader_abi.h | 29 | ||||
| -rw-r--r-- | src/sl0ppty.h | 7 |
11 files changed, 566 insertions, 60 deletions
@@ -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: @@ -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); @@ -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. @@ -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++) { @@ -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 |
