aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPeter Geissler <peter@haxx.in>2026-09-20 20:23:23 +0200
committerPeter Geissler <peter@haxx.in>2026-09-20 20:23:23 +0200
commit07e1adf1124657aaea4fa32874282812281e4889 (patch)
treeab63600e4438260e3e5c650bc711645fe21d066a
parentsidebar: a status can have more than one row (tab_bar_status_lines) (diff)
downloadslosh-07e1adf1124657aaea4fa32874282812281e4889.tar.gz
slosh-07e1adf1124657aaea4fa32874282812281e4889.tar.xz
slosh-07e1adf1124657aaea4fa32874282812281e4889.zip
sidebar: air between tabs (tab_gap)
A tab carrying three rows of wrapped status and the tab under it carrying three more read as one column of text: the labels are in there, but the list has stopped looking like a list. tab_gap is the blank rows between them, 0 by default, which is exactly what the sidebar has always drawn. It is the space *between* tabs and nothing else. Above the first tab is tab_bar_pad's and before the button is newtab_pad's, so the gap is applied before each label except the first and none of the three is ever paid twice. Given up a row at a time when the list is short of room, ahead of every label and the button, which is the same order the pad and the statuses already follow: a gap that pushed a tab off the bottom would be spacing costing you the thing it spaces. Paint rather than layout, so tuning it repaints without reflowing any pane. Sidebars only -- the top strip is one row, and the space between tabs there is the label's own padding.
-rw-r--r--docs/config.md2
-rw-r--r--docs/panes.md9
-rw-r--r--docs/slosh.59
-rw-r--r--include/config.h8
-rw-r--r--src/app_draw.c17
-rw-r--r--src/config.c6
-rw-r--r--tests/test_tab_bar.py64
7 files changed, 114 insertions, 1 deletions
diff --git a/docs/config.md b/docs/config.md
index 6fa0300..fa287f1 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -83,7 +83,7 @@ theme { frame_focus "#00ff88" } // ...but that one colour is mine
| group | what is in it |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| geometry | `gap` `gap_aspect` `padding` `compact` `rounded` `title_align` `title_inset` `min_pane` `min_split` |
-| chrome | `status_bar` `status_line` `status_pad` `tab_bar_side` `tab_bar_width` `tab_bar_pad` `tab_bar_status` `tab_bar_status_lines` `tab_bar_chrome` `hints` `version_banner` `pane_buttons` `bell_indicator` `newtab_button` `newtab_pad` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark`) |
+| chrome | `status_bar` `status_line` `status_pad` `tab_bar_side` `tab_bar_width` `tab_bar_pad` `tab_gap` `tab_bar_status` `tab_bar_status_lines` `tab_bar_chrome` `hints` `version_banner` `pane_buttons` `bell_indicator` `newtab_button` `newtab_pad` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark`) |
| behaviour | `focus_follows_mouse` `ctrl_d_exits` `scroll_lines` `scrollback` `scrollback_bytes` `toast_ms` `splash_ms` `hover_delay_ms` `double_click_ms` `word_separators` `anim_ms` `modal_scrim` `dim_unfocused` `float_shadow` `keep_dead` `in_band_shaders` `multi_attach` `attach_indicator` `size_follows` `shell` `editor` |
| projects | `project_roots` `project_layout` (see [workspaces](workspaces.md)) |
| colour | `theme { }` |
diff --git a/docs/panes.md b/docs/panes.md
index 6cc1055..96fefc8 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -56,6 +56,7 @@ The strip of tabs sits along the top by default. It does not have to:
tab_bar_side "left" // or "right"; "top" is the default
tab_bar_width 18 // columns the sidebar takes
tab_bar_pad 0 // blank rows above the first tab
+tab_gap 0 // ...and between one tab and the next
tab_bar_chrome true // frame it like a pane
tab_bar_status_lines 1 // rows one status may wrap onto
newtab_button true // the `+` at the end of the list
@@ -95,6 +96,14 @@ way: **the statuses give way before the tabs do**, because the list is how you
navigate and a status is only an annotation on it — a talkative pane must not
be able to cost you a tab you can click.
+**`tab_gap` puts air between one tab and the next**, which is what stops a
+list of tabs that each carry a paragraph of status from reading as one column
+of text. It is the space *between* tabs only: above the first is `tab_bar_pad`
+and before the `+` is `newtab_pad`, so none of the three is paid twice. Like
+them it is paint rather than layout, and like them it is given up a row at a
+time when the sidebar runs short — a gap must never be what costs you the tab
+it was separating.
+
**A status can be given more than one row.** `tab_bar_status_lines 3` word-wraps
it instead of cutting it, which is the difference between `add a summary…` and
the sentence — three rows of a sixteen-column sidebar is forty-eight cells. The
diff --git a/docs/slosh.5 b/docs/slosh.5
index 7fdd146..70cfa08 100644
--- a/docs/slosh.5
+++ b/docs/slosh.5
@@ -143,6 +143,15 @@ its inner line is the tab area's own ring, so the two meet in junctions and
come out as one figure. On by default; off gives the two columns back to the
labels.
.TP
+.BI tab_gap\ n
+Blank rows between one tab and the next in a sidebar. Tabs that each carry a
+paragraph of status read as one column of text; this makes them separate
+entries again. It is the space \fIbetween\fR tabs only \(em above the first
+is \fBtab_bar_pad\fR and before the button is \fBnewtab_pad\fR \(em and it
+is given up a row at a time when the list is short of room, so the gap can
+never be what costs you the tab it was separating. Paint rather than layout:
+panes keep the same rectangle whatever it is. Sidebars only.
+.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
diff --git a/include/config.h b/include/config.h
index d0aeccd..b6cbc86 100644
--- a/include/config.h
+++ b/include/config.h
@@ -339,6 +339,14 @@ 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;
+ /* Blank rows between one tab and the next, so a list of tabs each carrying
+ * a paragraph of status reads as separate entries rather than as one
+ * column of text. Sidebars only: the top strip is a single row, where the
+ * space between tabs is the label's own padding and there is none to give.
+ * Paint, not layout -- the panes' rectangle is the same at 0 as at 3 --
+ * and dropped a row at a time when the list is short of room, because the
+ * gap must never be what costs you the tab it was separating. */
+ uint16_t tab_gap;
/* 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
diff --git a/src/app_draw.c b/src/app_draw.c
index 863b7f0..a363143 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -2188,6 +2188,23 @@ void draw_tab_sidebar(app_t *a, screen_t *s) {
uint16_t btn_rows =
(CFG.newtab_button && cells(CFG.newtab_mark)) ? (uint16_t)1 : (uint16_t)0;
for (size_t i = 0; i < a->ntabs && y < limit; i++) {
+ /* Air between one tab and the next. Before the label rather than after
+ * the statuses, so it is the gap between two tabs and not a trailing
+ * margin under the last one -- the space before the first is
+ * tab_bar_pad's and the space before the button is newtab_pad's, and
+ * neither should be paid twice.
+ *
+ * Given up a row at a time when the list is short of room, ahead of
+ * anything that means something: a gap that pushed a tab off the bottom
+ * would be spacing costing you the thing it spaces. */
+ if (i) {
+ uint16_t g = CFG.tab_gap;
+ /* This tab's own label, the ones after it, and the button. */
+ uint16_t need = (uint16_t)(a->ntabs - i + btn_rows);
+ while (g && (uint16_t)(y + g + need) > limit) g--;
+ y = (uint16_t)(y + g);
+ if (y >= limit) break;
+ }
draw_tab_cell(a, s, i, cx, y, cw, dragging_pane);
y++;
if (!cap) continue;
diff --git a/src/config.c b/src/config.c
index f3662fd..b9e3c29 100644
--- a/src/config.c
+++ b/src/config.c
@@ -954,6 +954,7 @@ void config_defaults(config_t *c) {
* a gap nobody asked for. Still there for a bare sidebar, or for anyone
* who wants room at the top for a borderless window's own buttons. */
c->tab_bar_pad = 0;
+ c->tab_gap = 0; /* flush, the way the list has always read */
/* 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;
@@ -1686,6 +1687,8 @@ 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_gap %u // blank rows between a sidebar's tabs\n",
+ c->tab_gap);
cb_add(&b,
"tab_bar_status %u // pane status rows under each tab, 0 = off\n",
c->tab_bar_status);
@@ -2017,6 +2020,7 @@ static const char *const KNOWN_TOP[] = {
"tab_bar_status",
"tab_bar_status_lines",
"tab_bar_width",
+ "tab_gap",
"theme",
"title_align",
"title_inset",
@@ -2229,6 +2233,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_gap =
+ (uint16_t)kdl_arg_int(kdl_child(root, "tab_gap"), 0, c->tab_gap);
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(
diff --git a/tests/test_tab_bar.py b/tests/test_tab_bar.py
index a7b337a..e598d0e 100644
--- a/tests/test_tab_bar.py
+++ b/tests/test_tab_bar.py
@@ -305,6 +305,68 @@ def test_status_cap_spends_its_last_row_on_the_ellipsis():
)
+def test_tab_gap_is_air_between_tabs_and_nothing_else():
+ """Tabs each carrying a paragraph of status read as one column of text;
+ the gap is what makes them separate entries again. It belongs *between*
+ tabs: the space above the first is tab_bar_pad's and the space before the
+ button is newtab_pad's, and neither should be paid twice."""
+ n = 2
+ gapped = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_gap {n}\n')
+ with Session(SH, cols=90, rows=20, config=gapped) as s:
+ s.settle(30)
+ for _ in range(2):
+ s.api("new-tab")
+ s.settle(20)
+ snap = s.snapshot()
+ check(
+ "the first tab still starts where it always did",
+ snap.hit_at(CX, Y0) == "tab:1",
+ str(snap.hit_at(CX, Y0)),
+ )
+ check(
+ "the next one is that many rows lower",
+ snap.hit_at(CX, Y0 + 1 + n) == "tab:2",
+ str([snap.hit_at(CX, Y0 + 1 + k) for k in range(n + 1)]),
+ )
+ check(
+ "and the air between is nobody's target",
+ all(snap.hit_at(CX, Y0 + 1 + k) is None for k in range(n)),
+ str([snap.hit_at(CX, Y0 + 1 + k) for k in range(n)]),
+ )
+ # Third tab, then the button: the gap is not paid again before the `+`.
+ third = Y0 + 2 * (1 + n)
+ check(
+ "the button keeps its own pad, not the gap",
+ snap.hit_at(CX, third + 1 + NT_PAD) == "newtab",
+ str(snap.hit_at(CX, third + 1 + NT_PAD)),
+ )
+ p = s.pane(0)
+ check(
+ "the gap is paint, not layout: panes keep their rows",
+ p["y"] <= 2,
+ str(p["y"]),
+ )
+
+
+def test_tab_gap_gives_way_before_a_tab_does():
+ """Spacing must never be what costs you the thing it spaces: a gap too
+ big for the rows available shrinks, one row at a time, rather than
+ pushing tabs off the bottom."""
+ huge = _cfg(
+ f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_status 0\ntab_gap 4\n'
+ )
+ with Session(SH, cols=90, rows=12, config=huge) as s:
+ s.settle(30)
+ for _ in range(4):
+ s.api("new-tab")
+ s.settle(20)
+ snap = s.snapshot()
+ targets = {h["action"] for h in snap.hits}
+ missing = [t["index"] for t in s.tabs() if f"tab:{t['index']}" not in targets]
+ check("every tab is still clickable", not missing, str(missing))
+ check("and the button survived too", "newtab" in targets, str(sorted(targets)))
+
+
def test_statuses_never_cost_a_tab_its_row():
"""The list is the navigation; a status is an annotation on it.
@@ -743,6 +805,8 @@ if __name__ == "__main__":
test_the_sidebar_button_says_what_it_does()
test_newtab_pad_is_the_air_before_the_button()
test_newtab_button_false_removes_it_everywhere()
+ test_tab_gap_is_air_between_tabs_and_nothing_else()
+ test_tab_gap_gives_way_before_a_tab_does()
test_statuses_never_cost_a_tab_its_row()
test_a_status_can_be_given_more_than_one_row()
test_one_line_still_cuts_at_the_column_not_the_word()