diff options
| author | Peter Geissler <peter@haxx.in> | 2026-09-20 20:23:23 +0200 |
|---|---|---|
| committer | Peter Geissler <peter@haxx.in> | 2026-09-20 20:23:23 +0200 |
| commit | 07e1adf1124657aaea4fa32874282812281e4889 (patch) | |
| tree | ab63600e4438260e3e5c650bc711645fe21d066a | |
| parent | sidebar: a status can have more than one row (tab_bar_status_lines) (diff) | |
| download | slosh-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.md | 2 | ||||
| -rw-r--r-- | docs/panes.md | 9 | ||||
| -rw-r--r-- | docs/slosh.5 | 9 | ||||
| -rw-r--r-- | include/config.h | 8 | ||||
| -rw-r--r-- | src/app_draw.c | 17 | ||||
| -rw-r--r-- | src/config.c | 6 | ||||
| -rw-r--r-- | tests/test_tab_bar.py | 64 |
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() |
