aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorPeter Geissler <peter@haxx.in>2026-09-20 19:55:03 +0200
committerPeter Geissler <peter@haxx.in>2026-09-20 19:55:03 +0200
commit65a694b194bd68f8c6ba4b961e2cc906b8738a6c (patch)
tree0c915581b19fd379b4da9e118814c022fefe4e1d /src
parentsidebar: a talkative pane can no longer cost you a tab (diff)
downloadslosh-65a694b194bd68f8c6ba4b961e2cc906b8738a6c.tar.gz
slosh-65a694b194bd68f8c6ba4b961e2cc906b8738a6c.tar.xz
slosh-65a694b194bd68f8c6ba4b961e2cc906b8738a6c.zip
sidebar: a status can have more than one row (tab_bar_status_lines)
Sixteen cells is not enough for a sentence, and a sentence is what a pane has to say: "add a summary…" is most of a status and none of its meaning. tab_bar_status_lines 3 word-wraps instead of cutting, and the same column carries forty-eight cells rather than sixteen. Wrapped, the rows under a tab stop being a list of panes and become a paragraph each, so the shape no longer says where one pane's status ends and the next begins -- both are italic text down one column. Alternate panes are drawn on alternate bands to say it instead: tab_status_stripe, derived from the theme like tab_status_fg, so a theme that has never heard of any of this still gets a band in its own family. Nothing is painted at one line, where there is no ambiguity to resolve and a band would take a translucent terminal's background away for nothing. 1 is the default and is the old behaviour exactly, down to cutting at the column rather than the last word before it: with no next row to move the word to, breaking early would spend cells on nothing. The reason to prefer a word boundary only exists once there is somewhere for the word to go. Rows count against tab_bar_status as before, so wrapping trades panes for words within the same allowance, and the allowance itself still gives way to the tabs. A status that will not fit whole is dropped whole and reported with the ellipsis, rather than shown as a fragment that reads like the whole thing. Every row of a status is the same `find:` door. tabstatus's buffer goes to 256 to match pane_status's own slot: at one line the drawing cut it anyway, but wrapped it is the whole string that has to survive the trip.
Diffstat (limited to 'src')
-rw-r--r--src/app_draw.c182
-rw-r--r--src/config.c21
2 files changed, 171 insertions, 32 deletions
diff --git a/src/app_draw.c b/src/app_draw.c
index 7312f0d..863b7f0 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -2013,7 +2013,12 @@ void draw_tab_strip(app_t *a, screen_t *s) {
* a `find:` target: clicking a status jumps to the pane that said it. */
struct tabstatus {
struct {
- char text[96];
+ /* Matches pane_status's own slot (pane.c), so what a pane said is what
+ * gets here: at one line the drawing cuts it to the sidebar's width
+ * anyway, but wrapped over several it is the whole string that has to
+ * survive the trip, and a shorter buffer would silently decide how much
+ * of it could ever be shown. */
+ char text[256];
uint32_t pane;
} row[16];
size_t n; /* rows kept */
@@ -2023,7 +2028,7 @@ struct tabstatus {
static void tabstatus_cb(node_t *n, void *ud) {
struct tabstatus *ts = ud;
if (!n->pane) return;
- char text[96] = {0};
+ char text[256] = {0};
if (!pane_alive(n->pane) && !pane_suspended(n->pane))
exit_words(n->pane, text, sizeof text);
else
@@ -2051,6 +2056,65 @@ static void fit_status(char *text, size_t cap, uint16_t budget) {
if (len + 4 <= cap) memcpy(text + len, "\u2026", 4);
}
+/* Force the ellipsis on, whether or not the row is full: fit_status only cuts
+ * a row that overflows, and a wrapped status can stop mid-sentence on a row
+ * with cells to spare. Silence there would read as "that is all it said". */
+static void mark_cut(char *row, size_t cap, uint16_t width) {
+ while (row[0] && (uint16_t)(cells(row) + 1) > width) {
+ size_t len = strlen(row);
+ do len--;
+ while (len && (row[len] & 0xc0) == 0x80);
+ row[len] = 0;
+ }
+ size_t len = strlen(row);
+ if (len + 4 <= cap) memcpy(row + len, "\u2026", 4);
+}
+
+#define STATUS_WRAP_MAX 8 /* rows one status may ever take */
+#define STATUS_ROW_CAP 256
+
+/* Break `text` into at most `max` rows of `width` cells, at a space where one
+ * offers itself and mid-word where none does -- a sixteen-cell column has
+ * words wider than itself in it (paths, hashes), and refusing to split them
+ * would leave the row empty. Returns rows written; the last is ellipsised if
+ * anything is left over. */
+static size_t wrap_cells(const char *text, uint16_t width, size_t max,
+ char out[][STATUS_ROW_CAP]) {
+ if (!width || !max) return 0;
+ size_t n = 0;
+ const char *p = text;
+ while (*p && n < max) {
+ while (*p == ' ') p++; /* a row never starts with the break it came from */
+ if (!*p) break;
+ size_t len = 0, brk = 0;
+ uint16_t used = 0;
+ while (p[len]) {
+ size_t clen = 1;
+ while ((p[len + clen] & 0xc0) == 0x80) clen++;
+ char one[8];
+ size_t copy = clen < sizeof one ? clen : sizeof one - 1;
+ memcpy(one, p + len, copy);
+ one[copy] = 0;
+ uint16_t w = cells(one);
+ if ((uint16_t)(used + w) > width) break;
+ used = (uint16_t)(used + w);
+ len += clen;
+ if (p[len] == ' ') brk = len; /* the last break point that still fits */
+ }
+ if (!p[len]) brk = len; /* what is left fits whole */
+ else if (!brk) brk = len; /* one long word: split it rather than stall */
+ if (!brk) break; /* not even one cell of room */
+ if (brk >= STATUS_ROW_CAP) brk = STATUS_ROW_CAP - 1;
+ memcpy(out[n], p, brk);
+ out[n][brk] = 0;
+ p += brk;
+ n++;
+ }
+ while (*p == ' ') p++;
+ if (*p && n) mark_cut(out[n - 1], STATUS_ROW_CAP, width);
+ return n;
+}
+
void draw_tab_sidebar(app_t *a, screen_t *s) {
bool dragging_pane = a->drag.kind == DRAG_TITLE && a->drag.moved;
bool left = app_tab_bar_side(a) == TAB_BAR_LEFT;
@@ -2151,43 +2215,97 @@ void draw_tab_sidebar(app_t *a, screen_t *s) {
* The "and more" row is measured against the smaller of the two, so a
* list cut short by the height still says it was cut. */
size_t allow = cap < room ? cap : room;
- size_t show = ts.total > allow ? allow : (ts.n < allow ? ts.n : allow);
- for (size_t j = 0; j < show && y < limit; j++, y++) {
- /* The allowance's last row is spent on saying there was more, rather
- * than on one more status pretending the list is complete. Measured
- * against `allow`, not the cap: a tab whose statuses were cut by the
- * height rather than by the setting is just as incomplete. */
- bool more = ts.total > allow && j == allow - 1;
- char text[112];
- /* One leading space, which is the label's own -- a status starts in the
- * column its tab's name starts in, rather than indented under it.
- *
- * It used to be three. Indentation is a fine way to say "this belongs
- * to the row above" and a poor way to spend a sixteen-column sidebar,
- * where three columns is a fifth of everything a status has to say
- * itself in. The slant below says the same thing in none of them. */
- if (more) {
- snprintf(text, sizeof text, " \u2026");
+
+ /* How many rows one status may wrap onto -- never more than this tab's
+ * whole allowance, since a paragraph that would push every other pane
+ * off the sidebar is not a paragraph anyone asked for. */
+ size_t lines = CFG.tab_bar_status_lines ? CFG.tab_bar_status_lines : 1;
+ if (lines > STATUS_WRAP_MAX) lines = STATUS_WRAP_MAX;
+ if (lines > allow) lines = allow;
+ /* The text column: everything but the leading space below. */
+ uint16_t tw = cw > 1 ? (uint16_t)(cw - 1) : 1;
+
+ /* Lay the statuses out before drawing any, because whether the last row
+ * has to become an ellipsis depends on what did not fit -- and with
+ * wrapping that is no longer "one row, one status". */
+ char rows[16][STATUS_WRAP_MAX][STATUS_ROW_CAP];
+ size_t nrows[16];
+ size_t want = 0;
+ for (size_t j = 0; j < ts.n; j++) {
+ if (lines <= 1) {
+ /* The unwrapped row, cut where the column ends rather than at the
+ * last word before it. A single line has no next row to move the
+ * word to, so breaking early would just spend cells on nothing --
+ * the reason to prefer a word boundary only exists once there is
+ * somewhere for the word to go. */
+ snprintf(rows[j][0], STATUS_ROW_CAP, "%s", ts.row[j].text);
+ fit_status(rows[j][0], STATUS_ROW_CAP, tw);
+ nrows[j] = 1;
} else {
- snprintf(text, sizeof text, " %s", ts.row[j].text);
- fit_status(text, sizeof text, cw);
+ nrows[j] = wrap_cells(ts.row[j].text, tw, lines, rows[j]);
}
- pad_cells(text, sizeof text, cw); /* so a themed band covers the row */
- /* Italic, and ambient in colour: these are annotations until pointed
- * at. The slant is what separates them from the labels, rather than a
- * colour, because a monochrome theme has no colour to spare and would
- * be left with none -- tab_status_fg/bg are there for themes that do.
- * 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, cx, y, cw, 1);
- screen_text(s, cx, y, text, hot ? TAB_HOVER : TAB_STATUS_FG,
- TAB_STATUS_BG, hot ? ATTR_BOLD : ATTR_ITALIC);
- if (!more) {
+ if (!nrows[j]) nrows[j] = 1, rows[j][0][0] = 0;
+ want += nrows[j];
+ }
+ /* Something was already dropped by the collector, or will not fit here. */
+ bool cut = ts.total > ts.n || want > allow;
+ size_t budget = cut && allow ? allow - 1 : allow; /* the "and more" row */
+ size_t nshow = 0, used = 0;
+ for (size_t j = 0; j < ts.n; j++) {
+ if (used + nrows[j] > budget) break;
+ used += nrows[j];
+ nshow++;
+ }
+ /* Only say "and more" if something really is missing: a status list that
+ * fits exactly must not claim otherwise. */
+ bool ell = nshow < ts.n || ts.total > ts.n;
+
+ for (size_t j = 0; j < nshow && y < limit; j++) {
+ /* Hover is tested over the whole status, not each row: the rows are
+ * one door between them, and lighting up only the line under the
+ * pointer would suggest they are separate ones. */
+ bool hot = ptr_on(a, cx, y, cw, (uint16_t)nrows[j]);
+ /* Alternate panes sit on alternate bands. Wrapped, the rows under a
+ * tab are a paragraph per pane rather than a line per pane, and the
+ * shape alone no longer says where one ends -- so the band does. At
+ * one line there is nothing to disambiguate and the row keeps the
+ * terminal's own background, which a translucent one cares about. */
+ color_t band = TAB_STATUS_BG;
+ if (lines > 1 && (j % 2)) band = TAB_STATUS_STRIPE;
+
+ for (size_t k = 0; k < nrows[j] && y < limit; k++, y++) {
+ char text[STATUS_ROW_CAP + 8];
+ /* One leading space, which is the label's own -- a status starts in
+ * the column its tab's name starts in, rather than indented under
+ * it.
+ *
+ * It used to be three. Indentation is a fine way to say "this
+ * belongs to the row above" and a poor way to spend a sixteen-column
+ * sidebar, where three columns is a fifth of everything a status has
+ * to say itself in. The slant below says the same thing in none of
+ * them, and the band says which pane said it. */
+ snprintf(text, sizeof text, " %s", rows[j][k]);
+ pad_cells(text, sizeof text, cw); /* so a themed band covers the row */
+ /* Italic, and ambient in colour: these are annotations until pointed
+ * at. The slant is what separates them from the labels, rather than
+ * a colour, because a monochrome theme has no colour to spare and
+ * would be left with none -- tab_status_fg/bg are there for themes
+ * that do. The hover brightening doubles as the affordance that the
+ * row is a door; the hint under the pointer says where it leads. */
+ screen_text(s, cx, y, text, hot ? TAB_HOVER : TAB_STATUS_FG, band,
+ hot ? ATTR_BOLD : ATTR_ITALIC);
char act[24];
snprintf(act, sizeof act, "find:%u", ts.row[j].pane);
hit_add(&s->hits, cx, y, cw, 1, act);
}
}
+ if (ell && y < limit && used < allow) {
+ char text[STATUS_ROW_CAP + 8];
+ snprintf(text, sizeof text, " \u2026");
+ pad_cells(text, sizeof text, cw);
+ screen_text(s, cx, y, text, TAB_STATUS_FG, TAB_STATUS_BG, ATTR_ITALIC);
+ y++; /* an ellipsis is not a door: no hit */
+ }
}
if (CFG.newtab_button && cells(CFG.newtab_mark)) {
diff --git a/src/config.c b/src/config.c
index 7c428f7..f3662fd 100644
--- a/src/config.c
+++ b/src/config.c
@@ -657,6 +657,7 @@ static const struct {
{"tab_count", offsetof(config_t, tab_count)},
{"tab_status_fg", offsetof(config_t, tab_status_fg)},
{"tab_status_bg", offsetof(config_t, tab_status_bg)},
+ {"tab_status_stripe", offsetof(config_t, tab_status_stripe)},
{"status", offsetof(config_t, status)},
{"status_state", offsetof(config_t, status_state)},
{"finder_fg", offsetof(config_t, finder_fg)},
@@ -956,6 +957,10 @@ void config_defaults(config_t *c) {
/* 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;
+ /* One row per pane, cut to the width: the shape the sidebar has always
+ * had. Raising it trades rows for words, which is a choice about how many
+ * tabs you keep more than about how much a pane has to say. */
+ c->tab_bar_status_lines = 1;
c->tab_bar_chrome = true;
c->status_line = true;
/* Deliberately wider than the panes' own margin (gap * gap_aspect = 2), so
@@ -1062,6 +1067,10 @@ void config_defaults(config_t *c) {
* to. The slant already says what it is; this says it is worth reading. */
c->tab_status_fg = blend(dim, accent, 40);
c->tab_status_bg = (color_t){0}; /* none: the terminal's own background */
+ /* Barely off the background: the stripe has to say "different pane", not
+ * "selected". Only drawn when statuses can wrap -- see tab_bar_status_lines
+ * -- so an unwrapped sidebar keeps its transparent rows. */
+ c->tab_status_stripe = blend(c->default_bg, accent, 12);
c->status = dim;
c->status_state = bright;
@@ -1680,6 +1689,10 @@ char *config_render(const config_t *c) {
cb_add(&b,
"tab_bar_status %u // pane status rows under each tab, 0 = off\n",
c->tab_bar_status);
+ cb_add(&b,
+ "tab_bar_status_lines %u // rows one status may wrap onto, 1 = cut "
+ "it\n",
+ c->tab_bar_status_lines);
cb_add(&b, "tab_bar_chrome %s // frame the sidebar like a pane\n",
yesno(c->tab_bar_chrome));
cb_add(&b, "status_line %s // the line along the bottom\n",
@@ -2002,6 +2015,7 @@ static const char *const KNOWN_TOP[] = {
"tab_bar_pad",
"tab_bar_side",
"tab_bar_status",
+ "tab_bar_status_lines",
"tab_bar_width",
"theme",
"title_align",
@@ -2217,6 +2231,8 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
(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->tab_bar_status_lines = (uint16_t)kdl_arg_int(
+ kdl_child(root, "tab_bar_status_lines"), 0, c->tab_bar_status_lines);
c->tab_bar_chrome =
kdl_arg_bool(kdl_child(root, "tab_bar_chrome"), 0, c->tab_bar_chrome);
c->status_line =
@@ -2464,6 +2480,11 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
* split apply_scrolled makes. */
if (!kdl_child(theme, "tab_status_fg"))
c->tab_status_fg = blend(c->tab_count, c->tab_hover, 40);
+ /* The alternate band, on the same terms. A tenth of the way from the
+ * tab bar's own background toward the accent: enough to read as a
+ * different row at a glance, not enough to read as a highlight. */
+ if (!kdl_child(theme, "tab_status_stripe"))
+ c->tab_status_stripe = blend(c->default_bg, c->tab_hover, 12);
}
/* After the theme, so the wash follows whatever scroll_bg the theme just
* chose -- a config that wrote its own `scrolled` chain keeps it, whether