diff options
| author | user <user@clank> | 2026-08-14 19:20:25 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 19:20:25 +0200 |
| commit | 0de9bbc8ea84f8ad766abbf602e603fd90bd2a66 (patch) | |
| tree | e56992e66052a2c196709e6aa755fa3d092f5828 | |
| parent | sl0ppty: one theme name per decision, and a tab you can see (diff) | |
| download | slosh-0de9bbc8ea84f8ad766abbf602e603fd90bd2a66.tar.gz slosh-0de9bbc8ea84f8ad766abbf602e603fd90bd2a66.tar.xz slosh-0de9bbc8ea84f8ad766abbf602e603fd90bd2a66.zip | |
sl0ppty: a pane that rings says so
libghostty-vt already had a BEL effect; nothing was listening. A pane now marks
its titlebar when one arrives, and stays marked until the pane is looked at.
It marks the *tab* as well, which is the half that makes it useful. A bell
exists for the pane you are not watching, and a mark that only appeared on
screen would be missing precisely when the pane is in a background tab — which
is where an agent finishing a job, or a build wanting input, actually happens.
Looking at a pane is the acknowledgement, and it is done once per frame before
anything is drawn rather than while the pane itself is painted. The tab strip is
drawn first, so clearing during the pane's own paint left the strip reporting a
bell that had already been answered for one frame — visible as a stale dot that
went away on the next unrelated repaint. Doing it up front also means every
route to "this pane is focused" clears it, not only the routes that thought to.
The mark is a configurable string rather than a fixed glyph. The obvious choice
is an emoji bell, emoji are drawn two columns wide by many terminals, and
screen_text books every chrome cell as one — so the default is a narrow bullet
and anyone who knows their own terminal can say otherwise. config.kdl has no \\u
escape, so it holds the character itself; that cost a round trip to notice.
bell_indicator false makes a bell silent and invisible, which is a real choice
and not an oversight, so it is spelled out in the config.
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | config/config.kdl | 16 | ||||
| -rw-r--r-- | src/app.c | 44 | ||||
| -rw-r--r-- | src/config.c | 9 | ||||
| -rw-r--r-- | src/config.h | 11 | ||||
| -rw-r--r-- | src/pane.c | 22 | ||||
| -rw-r--r-- | src/sl0ppty.h | 3 | ||||
| -rw-r--r-- | tests/test_bell.py | 188 |
8 files changed, 296 insertions, 2 deletions
@@ -85,6 +85,11 @@ copy the same way (OSC 52) and can raise a notification (OSC 9), which appears as a toast in the corner — as do `{"cmd":"notify","text":"..."}` and things like "copied 13 chars". +A **BEL** from a program marks the pane it came from and the tab that pane is +in, until you look at it. A bell is for the pane you are *not* watching, so one +that only showed on screen would be useless in a background tab. `bell_indicator +false` turns it off; `bell_mark` chooses the character. + **Shaders** are colour passes over a pane's *contents* — never the frame, the title or the tab strip, so chrome stays legible over a pane that has been dimmed underneath it. A `states { }` block says what a pane looks like while diff --git a/config/config.kdl b/config/config.kdl index 70b1a8a..7a68933 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -29,6 +29,22 @@ title_align "center" // of this, so it can never end up welded to the rule beside it. title_inset 2 +// ── the bell ──────────────────────────────────────────────────────────────── +// +// A BEL from a program marks the pane it came from, and the tab that pane is +// in -- a bell exists for the pane you are *not* looking at, and one that only +// showed on screen would be useless in a background tab. Looking at the pane +// is what puts it out. Off makes a bell silent *and* invisible, which is a +// real choice rather than an oversight. +bell_indicator true + +// What to draw. A short string rather than a fixed glyph, because the obvious +// choice is an emoji bell and emoji are drawn two columns wide by many +// terminals while chrome here is booked as one -- so the default is narrow and +// anyone who knows their own terminal can say otherwise. (This file has no +// \u escape, so put the character itself here.) +bell_mark "•" + // The one-row bar with tabs, the +tab button and the pane count. status_bar true @@ -92,6 +92,7 @@ bool app_reload_config(char *err, size_t errcap) { #define TOAST_BG (CFG.toast_bg) #define RENAME_FG (CFG.rename_fg) #define RENAME_BG (CFG.rename_bg) +#define BELL_C (CFG.bell) static const color_t NO_COLOR = {0}; @@ -1656,6 +1657,12 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { screen_text(s, x1, y, vbar, fg, NO_COLOR, attrs); } + /* A pane that rang, marked just inside its corner: the same place on every + * pane, whatever its title is doing. */ + if (CFG.bell_indicator && pane_bell(leaf->pane) && r.w > 4) + screen_text(s, (uint16_t)(r.x + 1), r.y, CFG.bell_mark, BELL_C, NO_COLOR, + ATTR_BOLD); + /* The frame's top row is the drag handle. Registered before the split * button, which is painted after and therefore wins its own cell. */ { @@ -1818,6 +1825,12 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) { screen_text(s, r.x, r.y, tl, rule, NO_COLOR, 0); screen_text(s, x1, r.y, tr, rule, NO_COLOR, 0); + /* Just inside the corner, where it is in the same place on every pane + * whatever its title is doing. */ + if (CFG.bell_indicator && pane_bell(leaf->pane) && r.w > 4) + screen_text(s, (uint16_t)(r.x + 1), r.y, CFG.bell_mark, BELL_C, NO_COLOR, + ATTR_BOLD); + uint16_t tx = (uint16_t)(r.x + 1 + CFG.title_inset); if (tx < x1) { size_t room = (size_t)(x1 - tx); /* never over the closing corner */ @@ -1981,6 +1994,19 @@ static void draw_cb(node_t *n, void *ud) { draw_split_guide(d->a, d->s, n); } +struct bellsearch { + bool found; +}; +static void bell_cb(node_t *n, void *ud) { + struct bellsearch *b = ud; + if (pane_bell(n->pane)) b->found = true; +} +static bool tab_has_bell(tab_t *t) { + struct bellsearch b = {false}; + walk(t->root, bell_cb, &b); + return b.found; +} + static void draw_tab_strip(app_t *a, screen_t *s) { uint16_t x = CFG.status_pad; uint16_t y = CFG.gap; @@ -2007,8 +2033,14 @@ static void draw_tab_strip(app_t *a, screen_t *s) { tab_t *t = &a->tabs[i]; char label[80]; const char *nm = t->name[0] ? t->name : (t->purpose[0] ? t->purpose : ""); - if (nm[0]) snprintf(label, sizeof label, " %zu:%s ", i + 1, nm); - else snprintf(label, sizeof label, " %zu ", i + 1); + /* A pane that rang in a tab you are not looking at is invisible without + * this, and that is the case the whole indicator exists for. */ + const char *bell = (CFG.bell_indicator && tab_has_bell(t)) ? CFG.bell_mark : ""; + if (nm[0]) + snprintf(label, sizeof label, " %zu:%s%s%s ", i + 1, nm, + bell[0] ? " " : "", bell); + else + snprintf(label, sizeof label, " %zu%s%s ", i + 1, bell[0] ? " " : "", bell); bool active = i == a->cur; uint16_t attrs = active ? ATTR_BOLD : 0; @@ -2334,6 +2366,14 @@ void app_compose(app_t *a, screen_t *s) { s->cursor_visible = false; a->painted = s; if (!a->ntabs || !cur(a)->root) return; + + /* Looking at a pane is the acknowledgement, and it happens before anything + * is drawn rather than while the pane itself is: the tab strip is painted + * first and would otherwise spend a frame reporting a bell that had just + * been answered. Done here so that every route to "this pane is focused" + * clears it, not only the ones that thought to. */ + if (cur(a)->focus) pane_clear_bell(cur(a)->focus->pane); + layout(a); if (CFG.status_bar) draw_tab_strip(a, s); draw_node(a, s, cur(a)->root); diff --git a/src/config.c b/src/config.c index 11585d7..22d6665 100644 --- a/src/config.c +++ b/src/config.c @@ -169,6 +169,8 @@ void config_defaults(config_t *c) { c->rounded = true; c->title_align = ALIGN_CENTER; c->title_inset = 2; + c->bell_indicator = true; + snprintf(c->bell_mark, sizeof c->bell_mark, "\u2022"); c->min_pane_cols = 24; c->min_pane_rows = 6; c->min_split_cols = 32; @@ -246,6 +248,8 @@ void config_defaults(config_t *c) { c->finder_sel_fg = ink; c->finder_sel_bg = accent; + c->bell = accent; + c->rename_fg = ink; c->rename_bg = accent; @@ -358,6 +362,10 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { c->title_inset = (uint16_t)kdl_arg_int(kdl_child(root, "title_inset"), 0, c->title_inset); + c->bell_indicator = + kdl_arg_bool(kdl_child(root, "bell_indicator"), 0, c->bell_indicator); + const char *bm = kdl_arg(kdl_child(root, "bell_mark"), 0, NULL); + if (bm) snprintf(c->bell_mark, sizeof c->bell_mark, "%s", bm); const kdl_node_t *mins = kdl_child(root, "min_split"); if (mins) { @@ -450,6 +458,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { {"finder_bg", &c->finder_bg}, {"finder_sel_fg", &c->finder_sel_fg}, {"finder_sel_bg", &c->finder_sel_bg}, + {"bell", &c->bell}, {"rename_fg", &c->rename_fg}, {"rename_bg", &c->rename_bg}, {"toast_fg", &c->toast_fg}, diff --git a/src/config.h b/src/config.h index 869eeaf..cf3ed08 100644 --- a/src/config.h +++ b/src/config.h @@ -78,6 +78,14 @@ typedef struct { * always carries a space on each side on top of this, so a title can never * end up welded to the rule beside it. */ uint16_t title_inset; + /* Mark a pane's titlebar when a BEL arrives there and nobody has looked + * since. Off makes a bell silent *and* invisible, which is a real choice. */ + bool bell_indicator; + /* What to draw. A short string rather than a fixed glyph: the obvious + * choice is an emoji bell, and emoji are drawn two columns wide by many + * terminals while chrome here is booked as one — so the default is narrow + * and anyone who knows their terminal can say otherwise. */ + char bell_mark[16]; uint16_t min_pane_cols, min_pane_rows; /* The smallest pane a split is allowed to *produce*. min_pane is the point * below which the layout gives up and collapses a pane; this is the point @@ -169,6 +177,9 @@ typedef struct { /* the in-place editor a double-click opens on a pane's title */ color_t rename_fg, rename_bg; + /* the bell mark in a pane's titlebar */ + color_t bell; + /* announcements */ color_t toast_fg, toast_bg; @@ -26,6 +26,7 @@ struct pane { uint16_t cols, rows_n; bool alive; bool dirty; + bool bell; /* rang, and not yet looked at */ char title[256]; /* A name the user typed. It shadows `title` rather than overwriting it, so * the program's own title keeps arriving underneath and clearing the name @@ -180,6 +181,17 @@ static void on_notify(GhosttyTerminal t, void *ud, p->notify_cb(p, title, body, p->notify_ud); } +/* A pane rang. It stays rung until the pane is looked at, because the whole + * point is to survive not being looked at — a bell that cleared itself after a + * moment would be an indicator you can only see if you were already watching + * the pane that did not need your attention. */ +static void on_bell(GhosttyTerminal t, void *ud) { + (void)t; + pane_t *p = ud; + p->bell = true; + p->dirty = true; +} + static void on_title_changed(GhosttyTerminal t, void *ud) { pane_t *p = ud; GhosttyString s = {0}; @@ -263,6 +275,8 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_USERDATA, p); ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_WRITE_PTY, (const void *)(uintptr_t)on_write_pty); + ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_BELL, + (const void *)(uintptr_t)on_bell); ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, (const void *)(uintptr_t)on_title_changed); ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, @@ -323,6 +337,14 @@ const char *pane_title(const pane_t *p) { const char *pane_name(const pane_t *p) { return p->name; } +bool pane_bell(const pane_t *p) { return p->bell; } + +void pane_clear_bell(pane_t *p) { + if (!p->bell) return; + p->bell = false; + p->dirty = true; +} + void pane_set_name(pane_t *p, const char *name) { if (!name) name = ""; snprintf(p->name, sizeof p->name, "%s", name); diff --git a/src/sl0ppty.h b/src/sl0ppty.h index b7a1de2..9c6e8d1 100644 --- a/src/sl0ppty.h +++ b/src/sl0ppty.h @@ -156,6 +156,9 @@ void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total); bool pane_alt_screen(const pane_t *p); bool pane_wants_mouse(const pane_t *p); const char *pane_title(const pane_t *p); +/* A BEL arrived and the pane has not been looked at since. */ +bool pane_bell(const pane_t *p); +void pane_clear_bell(pane_t *p); /* The user-assigned name, "" when the program's title is showing through. */ const char *pane_name(const pane_t *p); /* "" clears the name and gives the pane back to the program's title. */ diff --git a/tests/test_bell.py b/tests/test_bell.py new file mode 100644 index 0000000..27307fc --- /dev/null +++ b/tests/test_bell.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""A pane that rang, and the several places that have to say so. + +A bell exists for the pane you are *not* looking at, so most of what matters is +where it shows up when the pane is off-screen, and that looking at it is what +puts it out. +""" +import os +import sys +import tempfile + +from harness import Session, check, report + +SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat'] +MARK = "\u2022" + + +def cfg(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +def lay(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +TWO_PANES = 'layout {\n tab name="api" {\n pane\n pane\n }\n}\n' +TWO_TABS = ('layout {\n tab name="api" {\n pane\n pane\n }\n' + ' tab name="notes" {\n pane\n }\n}\n') + + +def ring(s, pane_id, back_to): + """Ring the bell in a pane while looking at another one.""" + s.api("focus", id=pane_id) + s.settle(10) + s.raw(r"\x07") + s.settle(30) + s.api("focus", id=back_to) + s.settle(30) + + +def titlebar(snap, p, n=10): + return snap.line(p["y"])[p["x"]:p["x"] + n] + + +def test_a_bel_marks_the_pane_that_rang(): + l = lay(TWO_PANES) + with Session(SH, cols=90, rows=16, layout=l) as s: + s.settle(30) + panes = s.panes() + mine = [p for p in panes if p["focused"]][0] + other = [p for p in panes if not p["focused"]][0] + + check("no mark before anything rings", + MARK not in titlebar(s.snapshot(), other), "") + + ring(s, other["id"], mine["id"]) + check("the pane that rang is marked", + MARK in titlebar(s.snapshot(), other), + repr(titlebar(s.snapshot(), other))) + check("and the pane that did not is untouched", + MARK not in titlebar(s.snapshot(), mine), + repr(titlebar(s.snapshot(), mine))) + os.unlink(l) + + +def test_looking_at_it_is_what_puts_it_out(): + l = lay(TWO_PANES) + with Session(SH, cols=90, rows=16, layout=l) as s: + s.settle(30) + panes = s.panes() + mine = [p for p in panes if p["focused"]][0] + other = [p for p in panes if not p["focused"]][0] + + ring(s, other["id"], mine["id"]) + check("rung and unlooked-at", MARK in titlebar(s.snapshot(), other), "") + + s.api("focus", id=other["id"]) + s.settle(30) + check("focusing it clears the mark", + MARK not in titlebar(s.snapshot(), other), + repr(titlebar(s.snapshot(), other))) + + # It must stay out; a bell is not a thing that comes back on its own. + s.api("focus", id=mine["id"]) + s.settle(30) + check("and it stays cleared once answered", + MARK not in titlebar(s.snapshot(), other), + repr(titlebar(s.snapshot(), other))) + os.unlink(l) + + +def test_a_bell_in_another_tab_shows_on_the_strip(): + """The case the indicator exists for: the pane is not on screen at all.""" + l = lay(TWO_TABS) + with Session(SH, cols=90, rows=16, layout=l) as s: + s.settle(30) + tabs = s.tabs() + here = [p for p in s.panes() if p["focused"]][0] + away = [p for p in s.panes() if p["tab"] == 2][0] + + check("the strip is unmarked to start", + MARK not in s.snapshot().line(1), repr(s.snapshot().line(1))) + + s.api("select-tab", id=tabs[1]["id"]) + s.settle(20) + s.api("focus", id=away["id"]) + s.settle(10) + s.raw(r"\x07") + s.settle(30) + s.api("select-tab", id=tabs[0]["id"]) + s.api("focus", id=here["id"]) + s.settle(30) + + strip = s.snapshot().line(1) + check("the tab holding the rung pane is marked", MARK in strip, + repr(strip)) + check("and the mark sits with that tab's own label", + strip.index(MARK) > strip.index("notes"), repr(strip)) + + s.api("select-tab", id=tabs[1]["id"]) + s.settle(30) + check("visiting the tab clears it", + MARK not in s.snapshot().line(1), repr(s.snapshot().line(1))) + os.unlink(l) + + +def test_the_strip_and_the_pane_clear_together(): + """The acknowledgement happens before the frame is drawn, so the strip -- + which is painted first -- cannot spend a frame reporting a stale bell.""" + l = lay(TWO_PANES) + with Session(SH, cols=90, rows=16, layout=l) as s: + s.settle(30) + panes = s.panes() + mine = [p for p in panes if p["focused"]][0] + other = [p for p in panes if not p["focused"]][0] + ring(s, other["id"], mine["id"]) + + s.api("focus", id=other["id"]) + s.settle(30) + snap = s.snapshot() + check("neither the titlebar nor the strip is left marked", + MARK not in titlebar(snap, other) and MARK not in snap.line(1), + repr(titlebar(snap, other)) + " / " + repr(snap.line(1))) + os.unlink(l) + + +def test_it_can_be_turned_off_and_the_mark_chosen(): + l = lay(TWO_PANES) + off = cfg("bell_indicator false\n") + with Session(SH, cols=90, rows=16, config=off, layout=l) as s: + s.settle(30) + panes = s.panes() + mine = [p for p in panes if p["focused"]][0] + other = [p for p in panes if not p["focused"]][0] + ring(s, other["id"], mine["id"]) + snap = s.snapshot() + check("bell_indicator false says nothing anywhere", + MARK not in titlebar(snap, other) and MARK not in snap.line(1), + repr(titlebar(snap, other))) + os.unlink(off) + + custom = cfg('bell_mark "!"\n') + with Session(SH, cols=90, rows=16, config=custom, layout=l) as s: + s.settle(30) + panes = s.panes() + mine = [p for p in panes if p["focused"]][0] + other = [p for p in panes if not p["focused"]][0] + ring(s, other["id"], mine["id"]) + check("a chosen mark is the one drawn", + "!" in titlebar(s.snapshot(), other), + repr(titlebar(s.snapshot(), other))) + os.unlink(custom) + os.unlink(l) + + +if __name__ == "__main__": + test_a_bel_marks_the_pane_that_rang() + test_looking_at_it_is_what_puts_it_out() + test_a_bell_in_another_tab_shows_on_the_strip() + test_the_strip_and_the_pane_clear_together() + test_it_can_be_turned_off_and_the_mark_chosen() + sys.exit(report()) |
