diff options
| author | user <user@clank> | 2026-09-10 15:42:17 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-10 15:42:17 +0200 |
| commit | ecf609e3f903e05690ef21bb4f7dbd08e6d8e91d (patch) | |
| tree | ddf7b66b4e5938f9205e4a901899e23fb3ea4537 | |
| parent | sidebar: a row of air on top (tab_bar_pad), indicators under the labels' inset (diff) | |
| download | slosh-ecf609e3f903e05690ef21bb4f7dbd08e6d8e91d.tar.gz slosh-ecf609e3f903e05690ef21bb4f7dbd08e6d8e91d.tar.xz slosh-ecf609e3f903e05690ef21bb4f7dbd08e6d8e91d.zip | |
sidebar: what each tab's panes are saying, said under the tab (tab_bar_status)
Panes already announce a one-line status over OSC 5577, and it rendered
in exactly two places -- both inside the pane's own frame, which is the
one place you are not looking when you most want it. The sidebar has
the vertical room the top strip never had: under each tab, up to
tab_bar_status rows of what the panes inside said, in tree order, dim
like the pane count. A dead pane's row says how it died instead
(exit_words), the same substitution its own frame makes.
The cap's last row is spent on an ellipsis rather than one more status
pretending the list is complete, and a status cut to the sidebar's
width ends in one too -- 'building 3' is a different fact than
'building 3/7'. Each row is a find: hit, the verb that already means
'go to this pane', so clicking a status jumps to the pane that said it,
across tabs. Freshness costs nothing new: an OSC status marks the pane
dirty, the same path the bell indicator rides out of background tabs.
| -rw-r--r-- | docs/slosh.5 | 6 | ||||
| -rw-r--r-- | include/config.h | 6 | ||||
| -rw-r--r-- | src/app_draw.c | 80 | ||||
| -rw-r--r-- | src/config.c | 9 | ||||
| -rw-r--r-- | tests/test_tab_bar.py | 89 |
5 files changed, 189 insertions, 1 deletions
diff --git a/docs/slosh.5 b/docs/slosh.5 index c605ab4..367d79e 100644 --- a/docs/slosh.5 +++ b/docs/slosh.5 @@ -135,6 +135,12 @@ The strip's indicators move to the sidebar's bottom rows; the status line keeps the full width. A terminal too narrow to give up the columns falls back to \fBtop\fR until it grows. .TP +.BI tab_bar_status\ n +Under each of a sidebar's tabs, up to \fIn\fR rows of what the panes inside +said through OSC 5577 status \(em or how they died \(em with an ellipsis when +there was more. Clicking a status row jumps to the pane that said it. +0 turns it off; \fBtop\fR has no room for it either way. +.TP .BI hints\ bool What the pointer is resting on, said in the status line. .TP diff --git a/include/config.h b/include/config.h index 70596ec..b01d4a6 100644 --- a/include/config.h +++ b/include/config.h @@ -326,6 +326,12 @@ typedef struct { * own window buttons when the multiplexer fills a borderless window. * Ignored for top, which has exactly one row and no air to give. */ uint16_t tab_bar_pad; + /* Status rows under each of a sidebar's tabs: what the panes inside said + * through OSC 5577 (or how they died), at most this many rows per tab with + * an ellipsis when there was more. The sidebar is what makes this possible + * at all -- the top strip has one row and no room to say any of it. 0 turns + * it off. */ + uint16_t tab_bar_status; 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. * Separate from `gap`, which is the space around the *panes*: the two happen diff --git a/src/app_draw.c b/src/app_draw.c index 2cf22d0..5d358ad 100644 --- a/src/app_draw.c +++ b/src/app_draw.c @@ -1954,6 +1954,52 @@ void draw_tab_strip(app_t *a, screen_t *s) { * budgeting rule the horizontal strip applies from the right, so a long tab * list can never eat them. The status line below keeps the full width; the * sidebar stops above it. */ +/* The status rows a tab contributes to the sidebar: what each pane inside + * said through OSC 5577, or -- for a pane that is dead rather than suspended + * -- how it died, which is the same substitution the pane's own frame makes + * (draw_pane_status): a program that is gone cannot mean whatever it last + * asked us to show. Collected in tree order, pane id kept so the row can be + * a `find:` target: clicking a status jumps to the pane that said it. */ +struct tabstatus { + struct { + char text[96]; + uint32_t pane; + } row[16]; + size_t n; /* rows kept */ + size_t total; /* rows there were, so the cap can say "and more" */ +}; + +static void tabstatus_cb(node_t *n, void *ud) { + struct tabstatus *ts = ud; + if (!n->pane) return; + char text[96] = {0}; + if (!pane_alive(n->pane) && !pane_suspended(n->pane)) + exit_words(n->pane, text, sizeof text); + else + snprintf(text, sizeof text, "%s", pane_status(n->pane)); + if (!text[0]) return; + ts->total++; + if (ts->n >= sizeof ts->row / sizeof *ts->row) return; + snprintf(ts->row[ts->n].text, sizeof ts->row[0].text, "%s", text); + ts->row[ts->n].pane = n->id; + ts->n++; +} + +/* Cut `text` to `budget` cells at a UTF-8 boundary, saying so: the last cell + * becomes an ellipsis, because a status cut silently reads as complete -- + * "building 3" is a different fact than "building 3/7". */ +static void fit_status(char *text, size_t cap, uint16_t budget) { + if (cells(text) <= budget) return; + while (text[0] && cells(text) > (uint16_t)(budget > 0 ? budget - 1 : 0)) { + size_t len = strlen(text); + do len--; + while (len && (text[len] & 0xc0) == 0x80); + text[len] = 0; + } + size_t len = strlen(text); + if (len + 4 <= cap) memcpy(text + len, "\u2026", 4); +} + void draw_tab_sidebar(app_t *a, screen_t *s) { bool dragging_pane = a->drag.kind == DRAG_TITLE && a->drag.moved; uint16_t sw = app_tab_bar_cols(); @@ -1989,8 +2035,40 @@ void draw_tab_sidebar(app_t *a, screen_t *s) { } } - for (size_t i = 0; i < a->ntabs && y < limit; i++, y++) + uint16_t cap = CFG.tab_bar_status; + if (cap > 16) cap = 16; /* the collector keeps no more than that */ + for (size_t i = 0; i < a->ntabs && y < limit; i++) { draw_tab_cell(a, s, i, x, y, sw, dragging_pane); + y++; + if (!cap) continue; + + struct tabstatus ts = {0}; + walk(a->tabs[i].root, tabstatus_cb, &ts); + size_t show = ts.total > cap ? cap : ts.n; + for (size_t j = 0; j < show && y < limit; j++, y++) { + /* The cap's last row is spent on saying there was more, rather than on + * one more status pretending the list is complete. */ + bool more = ts.total > cap && j == (size_t)(cap - 1); + char text[112]; + if (more) { + snprintf(text, sizeof text, " \u2026"); + } else { + snprintf(text, sizeof text, " %s", ts.row[j].text); + fit_status(text, sizeof text, sw); + } + /* Dim like the pane count: these are ambience until pointed at. The + * hover brightening doubles as the affordance that the row is a door; + * the hint under the pointer says where it leads. */ + bool hot = !more && ptr_on(a, x, y, sw, 1); + screen_text(s, x, y, text, hot ? TAB_HOVER : TAB_COUNT, NO_COLOR, + hot ? ATTR_BOLD : 0); + if (!more) { + char act[24]; + snprintf(act, sizeof act, "find:%u", ts.row[j].pane); + hit_add(&s->hits, x, y, sw, 1, act); + } + } + } if (cells(CFG.newtab_mark) && y < limit) draw_newtab_button(a, s, x, y, dragging_pane); diff --git a/src/config.c b/src/config.c index d800696..48442e0 100644 --- a/src/config.c +++ b/src/config.c @@ -927,6 +927,9 @@ void config_defaults(config_t *c) { * as cramped, and in compact mode the row lines the first tab up with the * pane content inside the ring rather than with the ring's top line. */ c->tab_bar_pad = 1; + /* Three is enough for "the build, the tests, and one more thing" without a + * chatty tab pushing the list off the bottom. */ + c->tab_bar_status = 3; c->status_line = true; /* Deliberately wider than the panes' own margin (gap * gap_aspect = 2), so * the strip and the line read as chrome sitting outside the layout rather @@ -1631,6 +1634,9 @@ char *config_render(const config_t *c) { c->tab_bar_width); cb_add(&b, "tab_bar_pad %u // blank rows above a sidebar's tabs\n", c->tab_bar_pad); + cb_add(&b, + "tab_bar_status %u // pane status rows under each tab, 0 = off\n", + c->tab_bar_status); cb_add(&b, "status_line %s // the line along the bottom\n", yesno(c->status_line)); cb_add(&b, "status_pad %u\n", c->status_pad); @@ -1941,6 +1947,7 @@ static const char *const KNOWN_TOP[] = { "status_pad", "tab_bar_pad", "tab_bar_side", + "tab_bar_status", "tab_bar_width", "theme", "title_align", @@ -2154,6 +2161,8 @@ static bool load_into(config_t *c, const char *path, int depth, char *err, c->tab_bar_width); c->tab_bar_pad = (uint16_t)kdl_arg_int(kdl_child(root, "tab_bar_pad"), 0, c->tab_bar_pad); + c->tab_bar_status = (uint16_t)kdl_arg_int(kdl_child(root, "tab_bar_status"), + 0, c->tab_bar_status); c->status_line = kdl_arg_bool(kdl_child(root, "status_line"), 0, c->status_line); c->status_pad = diff --git a/tests/test_tab_bar.py b/tests/test_tab_bar.py index 842d421..9342905 100644 --- a/tests/test_tab_bar.py +++ b/tests/test_tab_bar.py @@ -119,6 +119,91 @@ def test_pad_pushes_the_list_down(): ) +def _status(s, text): + s.send(rf"\e]5577;1;status;{text}\e\\") + s.settle(30) + + +def test_status_rows_sit_under_their_tab(): + with Session(SH, cols=90, rows=20, config=LEFT) as s: + s.settle(30) + pane = s.pane(0)["id"] + _status(s, "building 3/7") + s.api("new-tab") + s.settle(30) + snap = s.snapshot() + check( + "a pane's OSC status is a row under its tab", + "building 3/7" in snap.line(3)[:W], + repr(snap.line(3)[:W]), + ) + check( + "the other tab moved down to make room", + snap.hit_at(1, 4) == "tab:2", + str(snap.hit_at(1, 4)), + ) + check( + "the row is a door to the pane that said it", + snap.hit_at(2, 3) == f"find:{pane}", + str(snap.hit_at(2, 3)), + ) + # We are in tab 2: clicking the status row is a cross-tab jump. + s.click(2, 3) + s.until(lambda _: s.tabs()[0]["active"]) + check("clicking it selects that tab", s.tabs()[0]["active"], str(s.tabs())) + check( + "...and focuses that pane", + s.focused()["id"] == pane, + str(s.focused()["id"]), + ) + + +def test_status_cap_spends_its_last_row_on_the_ellipsis(): + capped = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_status 1\n') + with Session(SH, cols=90, rows=20, config=capped) as s: + s.settle(30) + _status(s, "first") + s.api("split", dir="cols") + s.settle(30) + _status(s, "second") + snap = s.snapshot() + check( + "over the cap, the row says there was more", + snap.line(3)[:W].strip() == "\u2026", + repr(snap.line(3)[:W]), + ) + check( + "an ellipsis is not a door", + snap.hit_at(2, 3) is None, + str(snap.hit_at(2, 3)), + ) + check( + "and only the cap's rows were spent", + snap.hit_at(1, 4) == "newtab", + str(snap.hit_at(1, 4)), + ) + + +def test_a_long_status_is_cut_and_says_so(): + with Session(SH, cols=90, rows=20, config=LEFT) as s: + s.settle(30) + _status(s, "a status far too long for the sidebar") + row = s.snapshot().line(3)[:W] + check( + "the status stops at the sidebar's edge", row.endswith("\u2026"), repr(row) + ) + + +def test_a_dead_pane_reports_its_exit_instead(): + # Wider than the default: "exited: status 3" plus the indent is 19 cells, + # and the point here is the words, not the (already tested) truncation. + kept = _cfg('tab_bar_side "left"\ntab_bar_width 24\nkeep_dead "all"\n') + with Session(["/bin/sh", "-c", "exit 3"], cols=90, rows=20, config=kept) as s: + s.until(lambda snap: "exited" in snap.line(3)) + row = s.snapshot().line(3)[:24] + check("how it died replaces what it last said", "status 3" in row, repr(row)) + + def test_narrow_terminal_falls_back_to_top(): # 40 < width + min_pane cols + 4: the sidebar would leave no room for the # pane it is chrome for, so the strip goes back to the top row. @@ -153,6 +238,10 @@ if __name__ == "__main__": test_sidebar_rows_click_like_the_strip() test_right_sidebar_takes_the_other_edge() test_pad_pushes_the_list_down() + test_status_rows_sit_under_their_tab() + test_status_cap_spends_its_last_row_on_the_ellipsis() + test_a_long_status_is_cut_and_says_so() + test_a_dead_pane_reports_its_exit_instead() test_narrow_terminal_falls_back_to_top() test_growing_back_restores_the_sidebar() sys.exit(report()) |
