aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-22 11:59:10 +0200
committeruser <user@clank>2026-09-22 11:59:10 +0200
commitcb0d993fa284005d5679b628bb08d51f12812b8c (patch)
tree9d28b86f925255a24adb27306c6dde62e9f86292
parentsidebar: air between tabs (tab_gap) (diff)
downloadslosh-cb0d993fa284005d5679b628bb08d51f12812b8c.tar.gz
slosh-cb0d993fa284005d5679b628bb08d51f12812b8c.tar.xz
slosh-cb0d993fa284005d5679b628bb08d51f12812b8c.zip
tabs: a plate under the ones you are not in (tab_idle_bg)
The active tab has been filled with the accent since the strip existed; every other tab was words on the background. That leaves the strip with one shape on it and nothing to compare it against -- where a tab ends, how far the row you are about to click reaches, and in the sidebar which rows are tabs rather than statuses, all had to be read off the text. tab_idle_bg fills them. Barely off the background and in the label's own grey, because this is not a second highlight: it gives a tab edges, and it gives the accent something to be brighter than, which on a strip of short labels is most of how the eye finds the tab it is in. Hover keeps it for the same reason the active tab keeps its fill -- losing the plate under the pointer would read as the tab going away -- so hovering still lands on the text, and the bell mark sits on the plate rather than punching a hole in it. Derived a third of the way from default_bg to tab_idle when nobody names it, the same bargain tab_status_fg and tab_status_stripe already make: both ends are colours every theme in contrib defines, so each gets a plate in its own family -- paper lightens, phosphor greens, mono stays grey -- without a line added to any of them. Naming it wins, and naming your own background is how you ask for the bare tabs back. The `+ new tab` button and the gaps between tabs stay unpainted: the button is the one row in the list that does not mean "go here", and tab_gap is air between plates, which is what makes them read as separate tabs at all.
-rw-r--r--config/config.kdl7
-rw-r--r--docs/panes.md12
-rw-r--r--include/app_internal.h1
-rw-r--r--include/config.h8
-rw-r--r--src/app_draw.c27
-rw-r--r--src/config.c18
-rw-r--r--tests/test_tab_bar.py75
-rw-r--r--tests/test_tabs.py18
8 files changed, 152 insertions, 14 deletions
diff --git a/config/config.kdl b/config/config.kdl
index 2fda250..d4d5efe 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -844,6 +844,13 @@ theme {
tab_active_bg "#7aa2f7"
tab_active_hover_fg "#ffffff"
tab_idle "#45454a"
+ // Every other tab is a plate too, a third of the way from the background
+ // to the grey above: a tab with edges rather than a word floating on the
+ // background, and something for the active fill to be brighter *than*.
+ // Unset, it is mixed from those two, so a theme gets one in its own
+ // family without naming it. Name your own background here for the bare
+ // strip this had before.
+ tab_idle_bg "#141416"
tab_hover "#7aa2f7"
prefix_fg "#141418" // the "C-a" badge, while the prefix is pending
prefix_bg "#7aa2f7"
diff --git a/docs/panes.md b/docs/panes.md
index 96fefc8..087c873 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -126,6 +126,18 @@ yours wins; the band is unset by default, which leaves a translucent
terminal's own background alone. The top strip has one row and no room for any of this, which is half of
why the sidebar exists.
+**Every tab is a plate, not a word on the background.** The one you are in is
+filled with the accent (`tab_active_bg`); the rest sit on `tab_idle_bg`, a
+third of the way from the theme's background to the grey it writes idle labels
+in. Two things come of the quiet fill: a tab has edges, so where one ends, how
+far the row you are about to click reaches, and — in the sidebar especially —
+which rows are tabs rather than statuses are all visible; and the active fill
+has something to be brighter *than*, which on a strip of short labels is most
+of how the eye finds it. Unset, it is mixed from two colours every theme
+defines, so each gets a plate in its own family — paper lightens, phosphor
+greens. Name it and yours wins: set it to your own `default_bg` for the bare
+strip, or somewhere further along for a louder one.
+
**An unnamed tab borrows the directory its focused pane is in** — the
kernel's answer, so a `cd` moves the label with you, and home shows as `~`.
A real name or a [purpose](layouts.md#purposes) wins the moment one exists;
diff --git a/include/app_internal.h b/include/app_internal.h
index 97f6028..5b750ab 100644
--- a/include/app_internal.h
+++ b/include/app_internal.h
@@ -388,6 +388,7 @@ extern config_t CFG;
#define TAB_ACTIVE_BG (CFG.tab_active_bg)
#define TAB_ACTIVE_HOVER_FG (CFG.tab_active_hover_fg)
#define TAB_IDLE (CFG.tab_idle)
+#define TAB_IDLE_BG (CFG.tab_idle_bg)
#define TAB_HOVER (CFG.tab_hover)
#define PREFIX_FG (CFG.prefix_fg)
#define PREFIX_BG (CFG.prefix_bg)
diff --git a/include/config.h b/include/config.h
index b6cbc86..a042141 100644
--- a/include/config.h
+++ b/include/config.h
@@ -468,6 +468,14 @@ typedef struct {
* the tab you are in -- so its hover shows in the text instead. */
color_t tab_active_hover_fg;
color_t tab_idle, tab_hover;
+ /* The fill under every tab that is not the active one, so a tab reads as a
+ * plate rather than as words floating on the background -- and so the
+ * active tab's fill reads as *this one* against the rest, which is a
+ * comparison it could not make against nothing. Derived a third of the way
+ * from the background to `tab_idle`, the tab's own label grey, so it stays
+ * in a theme's family without any theme naming it. Set it to the theme's
+ * `default_bg` for the old bare look. */
+ color_t tab_idle_bg;
color_t prefix_fg, prefix_bg; /* the "C-a" badge */
color_t tab_count; /* how many panes in the session */
/* The sidebar's status rows. Their distinction from the labels above them
diff --git a/src/app_draw.c b/src/app_draw.c
index a363143..a8b40f5 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -1868,13 +1868,20 @@ static uint16_t draw_tab_cell(app_t *a, screen_t *s, size_t i, uint16_t x,
* hover colour if that width turns out to be under the pointer — which
* costs a repaint of a few cells and guarantees the lit cells are the
* registered ones. */
+ /* Every tab is a plate: the active one in the accent, the rest in
+ * `tab_idle_bg`. The quiet fill is what makes a strip of labels read as
+ * tabs — where one ends and the next begins, and how far the row the
+ * pointer will hit actually extends — and it is what the active fill is
+ * brighter *than*. A theme that wants the old bare strip sets it to its own
+ * background. */
uint16_t w = screen_text(
s, x, y, label, editing ? RENAME_FG : (active ? TAB_ACTIVE_FG : TAB_IDLE),
- editing ? RENAME_BG : (active ? TAB_ACTIVE_BG : NO_COLOR),
+ editing ? RENAME_BG : (active ? TAB_ACTIVE_BG : TAB_IDLE_BG),
editing ? ATTR_BOLD : attrs);
- /* Hovering keeps the active tab's fill — it is still the tab you are in —
- * so its feedback lands on the text instead. An inactive tab has no fill
- * to keep, and brightens. */
+ /* Hovering keeps the tab's own fill — the active one because it is still
+ * the tab you are in, an inactive one because losing its plate under the
+ * pointer would read as the tab going away — so the feedback lands on the
+ * text, which brightens. */
/* The bell keeps its own colour here too, rather than taking the tab's —
* an indicator drawn in the same dim grey as the label it sits next to is
* an indicator you have to already be looking for. */
@@ -1882,13 +1889,13 @@ static uint16_t draw_tab_cell(app_t *a, screen_t *s, size_t i, uint16_t x,
char mark[24];
snprintf(mark, sizeof mark, "%s ", CFG.bell_mark);
w = (uint16_t)(w + screen_text(s, (uint16_t)(x + w), y, mark, BELL_C,
- active ? TAB_ACTIVE_BG : NO_COLOR,
+ active ? TAB_ACTIVE_BG : TAB_IDLE_BG,
ATTR_BOLD));
}
uint16_t hit_w = max_w ? max_w : w;
if (!editing && ptr_on(a, x, y, hit_w, 1))
screen_text(s, x, y, label, active ? TAB_ACTIVE_HOVER_FG : TAB_HOVER,
- active ? TAB_ACTIVE_BG : NO_COLOR, attrs | ATTR_BOLD);
+ active ? TAB_ACTIVE_BG : TAB_IDLE_BG, attrs | ATTR_BOLD);
/* While a pane is in your hand, every tab it does not already live in is
* somewhere it could go, and `ptr_on` says nothing during a drag by design --
@@ -2101,9 +2108,11 @@ static size_t wrap_cells(const char *text, uint16_t width, size_t max,
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 (!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;
diff --git a/src/config.c b/src/config.c
index b9e3c29..d729287 100644
--- a/src/config.c
+++ b/src/config.c
@@ -651,6 +651,7 @@ static const struct {
{"tab_active_bg", offsetof(config_t, tab_active_bg)},
{"tab_active_hover_fg", offsetof(config_t, tab_active_hover_fg)},
{"tab_idle", offsetof(config_t, tab_idle)},
+ {"tab_idle_bg", offsetof(config_t, tab_idle_bg)},
{"tab_hover", offsetof(config_t, tab_hover)},
{"prefix_fg", offsetof(config_t, prefix_fg)},
{"prefix_bg", offsetof(config_t, prefix_bg)},
@@ -1058,6 +1059,13 @@ void config_defaults(config_t *c) {
c->tab_active_bg = accent;
c->tab_active_hover_fg = bright;
c->tab_idle = dim;
+ /* A plate under the tabs you are not in. Barely off the background and in
+ * the label's own grey: enough that a tab is a shape with edges, not enough
+ * to compete with the active tab's accent fill -- the strip has one
+ * highlight and this is not it. It also gives the active fill something to
+ * be brighter *than*, which on a strip of short labels is most of how the
+ * eye finds it. */
+ c->tab_idle_bg = blend(c->default_bg, dim, 30);
c->tab_hover = accent;
c->prefix_fg = ink;
c->prefix_bg = accent;
@@ -2233,8 +2241,7 @@ 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_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(
@@ -2486,6 +2493,13 @@ 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 idle tab plate, on the same terms: a third of the way from the
+ * theme's background to the grey it writes idle tab labels in. Every
+ * theme defines both, so each gets a plate in its own family -- paper
+ * lightens, phosphor greens -- and a theme that wants the bare strip back
+ * says `tab_idle_bg` in its own default_bg rather than editing this. */
+ if (!kdl_child(theme, "tab_idle_bg"))
+ c->tab_idle_bg = blend(c->default_bg, c->tab_idle, 30);
/* 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. */
diff --git a/tests/test_tab_bar.py b/tests/test_tab_bar.py
index e598d0e..d7ff86f 100644
--- a/tests/test_tab_bar.py
+++ b/tests/test_tab_bar.py
@@ -158,6 +158,72 @@ def _status(s, text):
s.settle(30)
+def test_every_tab_sits_on_a_plate():
+ """The tab you are in is the accent; the others are `tab_idle_bg`. The
+ quiet fill is what gives a tab edges -- and what the active fill is
+ brighter than."""
+ with Session(SH, cols=90, rows=20, config=LEFT) as s:
+ s.settle(30)
+ s.api("new-tab")
+ s.settle(30)
+ snap = s.snapshot()
+ idle, active = snap.style_at(CX, Y0), snap.style_at(CX, Y0 + 1)
+ check("the tab you are not in is filled", idle and idle["bg"], str(idle))
+ check(
+ "...in something other than the active tab's fill",
+ active and active["bg"] != idle["bg"],
+ f"{idle} vs {active}",
+ )
+ end = snap.style_at(CX + CW - 1, Y0)
+ check(
+ "and the plate is the whole row, which is the whole hit",
+ end and end["bg"] == idle["bg"],
+ str(end),
+ )
+
+
+def test_an_old_theme_gets_an_idle_plate_in_its_own_family():
+ """Same bargain as the status colours: mixed from two colours the theme
+ does define rather than one stock colour dropped into its palette."""
+ bg, idle = "#101020", "#88ff88"
+ old = _cfg(
+ f'tab_bar_side "left"\ntab_bar_width {W}\n'
+ f'theme {{ default_bg "{bg}"\n tab_idle "{idle}" }}\n'
+ )
+ with Session(SH, cols=90, rows=20, config=old) as s:
+ s.settle(30)
+ s.api("new-tab")
+ s.settle(30)
+ st = s.snapshot().style_at(CX, Y0)
+ got, a, b = _rgb(st["bg"]), _rgb(bg), _rgb(idle)
+ check(
+ "the plate is mixed from the theme's own background and label grey",
+ all(min(a[i], b[i]) <= got[i] <= max(a[i], b[i]) for i in range(3)),
+ f"{st['bg']} not between {bg} and {idle}",
+ )
+ check(
+ "...and nearer the background than the label",
+ sum(abs(got[i] - a[i]) for i in range(3))
+ < sum(abs(got[i] - b[i]) for i in range(3)),
+ st["bg"],
+ )
+
+
+def test_a_theme_can_have_the_bare_tabs_back():
+ """Naming the background is how you say "no plate" -- the knob wins over
+ the derivation, as every named colour does."""
+ bare = _cfg(
+ f'tab_bar_side "left"\ntab_bar_width {W}\n'
+ 'theme { default_bg "#101020"\n tab_idle_bg "#101020" }\n'
+ )
+ with Session(SH, cols=90, rows=20, config=bare) as s:
+ s.settle(30)
+ s.api("new-tab")
+ s.settle(30)
+ st = s.snapshot().style_at(CX, Y0)
+ check("the named colour is the one drawn", st["bg"] == "#101020", str(st))
+
+
def test_status_rows_sit_under_their_tab():
with Session(SH, cols=90, rows=20, config=LEFT) as s:
s.settle(30)
@@ -392,7 +458,11 @@ def test_statuses_never_cost_a_tab_its_row():
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 that exists can be clicked", not missing, str(missing))
- check("the button was not crowded out either", "newtab" in targets, str(sorted(targets)))
+ check(
+ "the button was not crowded out either",
+ "newtab" in targets,
+ str(sorted(targets)),
+ )
# The annotation is what gave way: with four labels and a button to
# fit into sixteen rows there is no room for twelve statuses, and it
# is the last tabs' statuses that are missing rather than their rows.
@@ -788,6 +858,9 @@ if __name__ == "__main__":
test_sidebar_rows_click_like_the_strip()
test_right_sidebar_takes_the_other_edge()
test_pad_pushes_the_list_down()
+ test_every_tab_sits_on_a_plate()
+ test_an_old_theme_gets_an_idle_plate_in_its_own_family()
+ test_a_theme_can_have_the_bare_tabs_back()
test_status_rows_sit_under_their_tab()
test_status_rows_are_told_apart_by_the_slant_not_by_an_indent()
test_status_rows_take_their_own_theme_colours()
diff --git a/tests/test_tabs.py b/tests/test_tabs.py
index 0583ad3..09b1eec 100644
--- a/tests/test_tabs.py
+++ b/tests/test_tabs.py
@@ -313,6 +313,7 @@ ACCENT = "#7aa2f7" # the fill of the tab you are in, and hover on the rest
INK = "#141418" # text on that fill
BRIGHT = "#ffffff" # that text while the pointer is on it
DIM = "#45454a" # a tab you are not in
+IDLE_BG = "#141416" # ...and its plate: a third of the way from bg to DIM
def two_tab_layout():
@@ -401,11 +402,24 @@ def test_weight_says_active_and_colour_says_pointer():
h for k, h in hits.items() if k.startswith("tab:") and h is not active
][0]
check(
- "a tab you are not in has no fill at all",
- bg_of(snap, inactive) is None and fg_of(snap, inactive) == DIM,
+ "a tab you are not in sits on the quiet plate instead",
+ bg_of(snap, inactive) == IDLE_BG and fg_of(snap, inactive) == DIM,
f"{fg_of(snap, inactive)} on {bg_of(snap, inactive)}",
)
+ hover(s, inactive["x"] + 1, inactive["y"])
+ snap = s.snapshot()
+ check(
+ "hovering an inactive tab brightens its text",
+ fg_of(snap, inactive) == ACCENT,
+ str(fg_of(snap, inactive)),
+ )
+ check(
+ "...and keeps its plate, rather than reading as the tab going away",
+ bg_of(snap, inactive) == IDLE_BG,
+ str(bg_of(snap, inactive)),
+ )
+
hover(s, active["x"] + 1, active["y"])
snap = s.snapshot()
check(