aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPeter Geissler <peter@haxx.in>2026-09-20 19:40:05 +0200
committerPeter Geissler <peter@haxx.in>2026-09-20 19:40:05 +0200
commit6c8d99cd71858955c0e9c9d93d738c855e652ce8 (patch)
treea02b83f33773df0119a669ba697dfeefe3971a29
parenttests: a pane's status is output, so write the pty, not the input decoder (diff)
downloadslosh-6c8d99cd71858955c0e9c9d93d738c855e652ce8.tar.gz
slosh-6c8d99cd71858955c0e9c9d93d738c855e652ce8.tar.xz
slosh-6c8d99cd71858955c0e9c9d93d738c855e652ce8.zip
tabs: air before the new-tab button, and the option to do without it
The `+` is the one entry in the strip that does not mean "go here", and it sits at the end of a list where every other entry does. Flush against them it is what you hit when you meant the last tab -- most easily in a sidebar, where the target is the full width of the row and the pointer only has to be one row low. newtab_pad is the nothing in between: blank rows in a sidebar, blank columns on top, one by default, which is enough to make the slip a miss. It is paint rather than layout, like tab_bar_pad: the panes' rectangle is the same at 0 as at 5, so tuning it repaints without reflowing anyone. And it is given up before the button is -- a strip short of room drops the spacing one cell at a time and still draws what the spacing was protecting, because spacing must never cost you the thing it spaces. newtab_button false removes the button outright, for people who make tabs from the keyboard and would rather have the row (or the columns) back. Only the place to click it goes: the chord and the palette entry still work. Setting newtab_mark "" already did this by omission; this says it on purpose, and says it in --dump-config.
-rw-r--r--config/config.kdl14
-rw-r--r--docs/config.md2
-rw-r--r--docs/panes.md11
-rw-r--r--docs/slosh.511
-rw-r--r--include/config.h13
-rw-r--r--src/app_draw.c23
-rw-r--r--src/config.c20
-rw-r--r--tests/test_tab_bar.py105
8 files changed, 181 insertions, 18 deletions
diff --git a/config/config.kdl b/config/config.kdl
index 5c0efbb..2fda250 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -217,6 +217,20 @@ min_mark "▼" // into the strip along the bottom, still running
// the border became the button. Try "[+]" or "new" if you prefer a word.
newtab_mark "+"
+// Whether that button is drawn at all, and how much nothing sits between it
+// and the last tab -- rows in a sidebar, columns in the top strip.
+//
+// The air is there because the button is the odd one out in a list whose every
+// other row means "go here": flush against the tabs, the row below the last one
+// is a new tab you did not ask for, and in a sidebar the whole width is the
+// target so the pointer only has to be one row low. One cell turns that into a
+// miss. It is paint, not layout -- the panes' rectangle is the same either way.
+//
+// Turn the button off if you make tabs from the keyboard (`C-a c`, or the
+// palette): the verb does not go anywhere, only the place to click it.
+newtab_button true
+newtab_pad 1
+
// Which dead panes stay, and which just go.
//
// commands panes that were *given* a command -- from a layout, or the
diff --git a/docs/config.md b/docs/config.md
index edebc8d..0da27a6 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_chrome` `hints` `version_banner` `pane_buttons` `bell_indicator` 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_bar_status` `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 f5fab23..1884b53 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -57,13 +57,22 @@ 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_bar_chrome true // frame it like a pane
+newtab_button true // the `+` at the end of the list
+newtab_pad 1 // ...and the air before it
```
A **sidebar lists one tab per row** down the chosen edge, and the panes trade
the strip's row for its columns. Everything the strip does travels with it:
click to switch, double-click to rename in place, drag a pane onto a tab or
onto the `+` — which, given a row of its own and the columns to spare, spells
-itself out as `+ new tab`. The pane count and the prefix badge move
+itself out as `+ new tab`. That button sits `newtab_pad` rows below the last
+tab (columns after it, in the top strip): it is the one row in the list that
+does something other than "go here", and flush against the tabs it is what you
+hit when you meant the last one. The air is paint, not layout — the panes keep
+their rectangle whatever it is, and it is given up before the button is when
+the strip runs out of room. Set `newtab_button false` to drop the button
+altogether; `new-tab` is still a [chord](keys.md) and a palette entry, and a
+pane can no longer be dropped onto it. The pane count and the prefix badge move
to the sidebar's bottom rows; the [status line](config.md) along the bottom
keeps the full width. A terminal too narrow to give up the columns falls back
to the top row until it grows.
diff --git a/docs/slosh.5 b/docs/slosh.5
index 4f22a06..ec2d6fe 100644
--- a/docs/slosh.5
+++ b/docs/slosh.5
@@ -163,6 +163,17 @@ Whether a pane's bell shows at all (the mark, and the frame effect).
.TP
.BR zoom_mark ,\ zoom_on_mark ,\ close_mark ,\ min_mark ,\ newtab_mark ,\ bell_mark
The glyphs themselves, each one string.
+.TP
+.BR newtab_button\ \fIbool\fR ,\ newtab_pad\ \fIn\fR
+The new-tab button at the end of the tab strip, and the air between it and
+the last tab \(em \fIn\fR blank rows in a sidebar, \fIn\fR blank columns in
+the top strip. The button is the one entry in the list that does not mean
+\[dq]go here\[dq], so flush against the tabs it is what a low pointer hits;
+one cell of nothing makes that a miss. The pad is dropped before the button
+is when the strip is short of room, and it is paint rather than layout \(em
+panes keep the same rectangle either way. Off, tabs are made from the
+keyboard (\fBnew-tab\fR) and a pane can no longer be dropped onto the button
+to get a tab of its own.
.SH BEHAVIOUR
.TP
.BI focus_follows_mouse\ bool
diff --git a/include/config.h b/include/config.h
index f7caeba..0f9d37e 100644
--- a/include/config.h
+++ b/include/config.h
@@ -192,6 +192,19 @@ typedef struct {
/* The new-tab button at the end of the strip. Drawn with a space each side,
* so a one-character mark is still a three-cell target. */
char newtab_mark[32];
+ /* Whether that button is drawn at all. On by default -- it is the only
+ * pointer-reachable way to make a tab -- but the verb is also a chord and a
+ * palette entry, so anyone who works from the keyboard can have the row (or
+ * the columns) back and stop hitting it by accident. Setting newtab_mark to
+ * "" does the same thing by omission; this says it on purpose. */
+ bool newtab_button;
+ /* Air between the last tab and that button: blank *rows* in a sidebar,
+ * blank *columns* in the top strip -- the axis the list runs along, either
+ * way. The button sits at the end of a list of tabs whose rows all do
+ * something else, and a destructive-enough neighbour one cell away is a
+ * misclick waiting to happen; one cell of nothing is the cheapest fix.
+ * Dropped rather than the button itself when the strip is out of room. */
+ uint16_t newtab_pad;
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
diff --git a/src/app_draw.c b/src/app_draw.c
index 3b329bf..1dabf98 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -1987,10 +1987,15 @@ void draw_tab_strip(app_t *a, screen_t *s) {
for (size_t i = 0; i < a->ntabs && x < right; i++)
x = (uint16_t)(x + draw_tab_cell(a, s, i, x, y, 0, dragging_pane));
- {
+ if (CFG.newtab_button) {
uint16_t bw = (uint16_t)(cells(CFG.newtab_mark) + 2);
- if (bw > 2 && x + bw <= right)
- draw_newtab_button(a, s, x, y, 0, dragging_pane);
+ /* The air before the button, in the axis this strip runs along. Given up
+ * before the button is, the same rule the sidebar's frame follows:
+ * spacing must not be what costs you the thing it is spacing. */
+ uint16_t pad = CFG.newtab_pad;
+ while (pad && x + pad + bw > right) pad--;
+ if (bw > 2 && x + pad + bw <= right)
+ draw_newtab_button(a, s, (uint16_t)(x + pad), y, 0, dragging_pane);
}
}
@@ -2158,8 +2163,16 @@ void draw_tab_sidebar(app_t *a, screen_t *s) {
}
}
- if (cells(CFG.newtab_mark) && y < limit)
- draw_newtab_button(a, s, cx, y, cw, dragging_pane);
+ if (CFG.newtab_button && cells(CFG.newtab_mark)) {
+ /* Blank rows between the list and the button. A sidebar tab is a target
+ * the full width of the strip, so the row under the last one is one slip
+ * of the pointer away from it -- air is what makes that slip a miss.
+ * Shrunk to fit rather than pushing the button off the bottom. */
+ uint16_t pad = CFG.newtab_pad;
+ while (pad && (uint16_t)(y + pad) >= limit) pad--;
+ y = (uint16_t)(y + pad);
+ if (y < limit) draw_newtab_button(a, s, cx, y, cw, dragging_pane);
+ }
}
/* The space between two of a split's children, or false if they are flush.
diff --git a/src/config.c b/src/config.c
index 7166208..7c428f7 100644
--- a/src/config.c
+++ b/src/config.c
@@ -887,6 +887,14 @@ void config_defaults(config_t *c) {
snprintf(c->close_mark, sizeof c->close_mark, "\u2715"); /* ✕ close */
snprintf(c->min_mark, sizeof c->min_mark, "\u25ac"); /* ▬ into the strip */
snprintf(c->newtab_mark, sizeof c->newtab_mark, "+");
+ c->newtab_button = true;
+ /* One cell of nothing between the last tab and the button. The tabs are a
+ * list where every row (or column) does one thing and the button does a
+ * different one, and flush against the list it is the thing you hit when
+ * you meant the tab above it -- most often in a sidebar, where the target
+ * is a whole row and the pointer only has to be one row low. One is enough
+ * to make the miss a miss rather than a new tab. */
+ c->newtab_pad = 1;
c->bell_indicator = true;
snprintf(c->bell_mark, sizeof c->bell_mark, "[!]");
c->keep_dead = KEEP_DEAD_COMMANDS;
@@ -1691,6 +1699,12 @@ char *config_render(const config_t *c) {
cb_qstr(&b, "close_mark", c->close_mark, NULL);
cb_qstr(&b, "min_mark", c->min_mark, NULL);
cb_qstr(&b, "newtab_mark", c->newtab_mark, NULL);
+ cb_add(&b, "newtab_button %s // the + at the end of the tab strip\n",
+ yesno(c->newtab_button));
+ cb_add(&b,
+ "newtab_pad %u // air before it: rows in a sidebar, "
+ "columns on top\n",
+ c->newtab_pad);
cb_qstr(&b, "bell_mark", c->bell_mark, NULL);
cb_add(&b, "\n// ---- behaviour ----\n");
@@ -1964,7 +1978,9 @@ static const char *const KNOWN_TOP[] = {
"multi_attach",
"min_split",
"modal_scrim",
+ "newtab_button",
"newtab_mark",
+ "newtab_pad",
"padding",
"pane_buttons",
"project_layout",
@@ -2258,6 +2274,10 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
if (mm) set_mark(c->min_mark, sizeof c->min_mark, mm);
const char *nt = kdl_arg(kdl_child(root, "newtab_mark"), 0, NULL);
if (nt) set_mark(c->newtab_mark, sizeof c->newtab_mark, nt);
+ c->newtab_button =
+ kdl_arg_bool(kdl_child(root, "newtab_button"), 0, c->newtab_button);
+ c->newtab_pad =
+ (uint16_t)kdl_arg_int(kdl_child(root, "newtab_pad"), 0, c->newtab_pad);
{
/* `commands` (the default), `all`, or `none`. `true`/`false` are taken as
* `all`/`none`, because that is what they used to mean here and a config
diff --git a/tests/test_tab_bar.py b/tests/test_tab_bar.py
index 85b6c48..1fb6609 100644
--- a/tests/test_tab_bar.py
+++ b/tests/test_tab_bar.py
@@ -33,6 +33,10 @@ BARE = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_chrome false\n')
# is one edit here rather than thirty coordinates below.
CX, CW, Y0 = 1, W - 2, 2
+# The default newtab_pad: one blank row between the last tab and the `+`, so
+# a pointer one row low misses the button instead of making a tab.
+NT_PAD = 1
+
SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
@@ -56,8 +60,13 @@ def test_left_sidebar_reserves_columns():
str(snap.hit_at(CX + CW - 1, Y0)),
)
check(
- "the + sits on the row after the last tab",
- snap.hit_at(CX, Y0 + 1) == "newtab",
+ "the + sits below the last tab, a pad row clear of it",
+ snap.hit_at(CX, Y0 + 1 + NT_PAD) == "newtab",
+ str(snap.hit_at(CX, Y0 + 1 + NT_PAD)),
+ )
+ check(
+ "and the air between them is nobody's target",
+ snap.hit_at(CX, Y0 + 1) is None,
str(snap.hit_at(CX, Y0 + 1)),
)
p = s.pane(0)
@@ -73,7 +82,8 @@ def test_left_sidebar_reserves_columns():
def test_sidebar_rows_click_like_the_strip():
with Session(SH, cols=90, rows=20, config=LEFT) as s:
s.settle(30)
- s.click(CX, Y0 + 1) # the + row: a new tab, which becomes the active one
+ # the + row: a new tab, which becomes the active one
+ s.click(CX, Y0 + 1 + NT_PAD)
s.until(lambda _: len(s.tabs()) == 2)
check("the + row makes a tab", len(s.tabs()) == 2, str(len(s.tabs())))
check("...and it is the one you are in", s.tabs()[1]["active"], str(s.tabs()))
@@ -290,8 +300,8 @@ def test_status_cap_spends_its_last_row_on_the_ellipsis():
)
check(
"and only the cap's rows were spent",
- snap.hit_at(CX, Y0 + 2) == "newtab",
- str(snap.hit_at(CX, Y0 + 2)),
+ snap.hit_at(CX, Y0 + 2 + NT_PAD) == "newtab",
+ str(snap.hit_at(CX, Y0 + 2 + NT_PAD)),
)
@@ -405,12 +415,83 @@ def test_the_sidebar_button_says_what_it_does():
with Session(SH, cols=90, rows=20, config=LEFT) as s:
s.settle(40)
snap = s.snapshot()
- row = _row(snap, Y0 + 1)
+ row = _row(snap, Y0 + 1 + NT_PAD)
check("a row of its own spells the verb out", "new tab" in row, repr(row))
check(
"and the whole row is the target, like a tab's",
- snap.hit_at(CX + CW - 1, Y0 + 1) == "newtab",
- str(snap.hit_at(CX + CW - 1, Y0 + 1)),
+ snap.hit_at(CX + CW - 1, Y0 + 1 + NT_PAD) == "newtab",
+ str(snap.hit_at(CX + CW - 1, Y0 + 1 + NT_PAD)),
+ )
+
+
+def test_newtab_pad_is_the_air_before_the_button():
+ """The button is the one row in the list that does not mean "go here", and
+ a sidebar tab is a target the full width of the strip: flush against the
+ tabs, a pointer one row low makes a tab instead of switching to one. The
+ pad is that air, and 0 gives the old flush button back."""
+ n = 3
+ padded = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\nnewtab_pad {n}\n')
+ with Session(SH, cols=90, rows=20, config=padded) as s:
+ s.settle(30)
+ snap = s.snapshot()
+ check(
+ "the air is not a 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)]),
+ )
+ check(
+ "the button is that many rows down",
+ snap.hit_at(CX, Y0 + 1 + n) == "newtab",
+ str(snap.hit_at(CX, Y0 + 1 + n)),
+ )
+ p = s.pane(0)
+ check(
+ "the pad is paint, not layout: panes keep their rows",
+ p["y"] <= 2,
+ str(p["y"]),
+ )
+
+ flush = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\nnewtab_pad 0\n')
+ with Session(SH, cols=90, rows=20, config=flush) as s:
+ s.settle(30)
+ snap = s.snapshot()
+ check(
+ "0 puts it straight under the last tab",
+ snap.hit_at(CX, Y0 + 1) == "newtab",
+ str(snap.hit_at(CX, Y0 + 1)),
+ )
+
+
+def test_newtab_button_false_removes_it_everywhere():
+ """The verb keeps working from the keyboard; only the place to click it
+ goes. A sidebar that is missing it has no `newtab` hit anywhere on it --
+ including as somewhere to drop a pane."""
+ off = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\nnewtab_button false\n')
+ with Session(SH, cols=90, rows=20, config=off) as s:
+ s.settle(30)
+ snap = s.snapshot()
+ check(
+ "no button on the sidebar",
+ all(h["action"] != "newtab" for h in snap.hits),
+ str([h for h in snap.hits if h["action"] == "newtab"]),
+ )
+ check(
+ "nor the word that spells it out",
+ all("new tab" not in snap.line(y) for y in range(20)),
+ repr([snap.line(y) for y in range(20) if "new tab" in snap.line(y)]),
+ )
+ s.api("new-tab")
+ s.until(lambda _: len(s.tabs()) == 2)
+ check("the verb still makes tabs", len(s.tabs()) == 2, str(len(s.tabs())))
+
+ top = _cfg("newtab_button false\n")
+ with Session(SH, cols=90, rows=20, config=top) as s:
+ s.settle(30)
+ snap = s.snapshot()
+ check(
+ "and the top strip loses it too",
+ all(h["action"] != "newtab" for h in snap.hits),
+ str([h for h in snap.hits if h["action"] == "newtab"]),
)
@@ -419,14 +500,14 @@ def test_a_narrow_sidebar_keeps_the_bare_mark():
with Session(SH, cols=90, rows=20, config=narrow) as s:
s.settle(40)
snap = s.snapshot()
- row = snap.line(Y0 + 1)[CX : CX + 8]
+ row = snap.line(Y0 + 1 + NT_PAD)[CX : CX + 8]
check(
"no room for the word, so it is not said", "new tab" not in row, repr(row)
)
check(
"...but the button is still there",
- snap.hit_at(CX, Y0 + 1) == "newtab",
- str(snap.hit_at(CX, Y0 + 1)),
+ snap.hit_at(CX, Y0 + 1 + NT_PAD) == "newtab",
+ str(snap.hit_at(CX, Y0 + 1 + NT_PAD)),
)
@@ -492,6 +573,8 @@ if __name__ == "__main__":
test_compact_shares_its_lines_with_the_ring()
test_chrome_false_gives_the_columns_back()
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_a_narrow_sidebar_keeps_the_bare_mark()
test_the_strip_keeps_its_bare_mark()
test_narrow_terminal_falls_back_to_top()