diff options
| author | user <user@clank> | 2026-10-09 12:24:23 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-10-09 12:31:53 +0200 |
| commit | 3a0825c6890eff566307c0eaad809cdbcf854efd (patch) | |
| tree | add5cc5d6e731bce8dcffe96fa92d64c84052143 | |
| parent | panes: the focus a closed tab left behind (diff) | |
| download | slosh-3a0825c6890eff566307c0eaad809cdbcf854efd.tar.gz slosh-3a0825c6890eff566307c0eaad809cdbcf854efd.tar.xz slosh-3a0825c6890eff566307c0eaad809cdbcf854efd.zip | |
tab bar: a sidebar that fits the terminal, and gives up the edge last
`tab_bar_width` was a reservation: the configured columns came off the
screen whatever the screen was, and the only fallback was all the way back
to the top row. On a narrow terminal that is chrome displacing the thing it
is chrome for, and the jump to the top moves every target you were aiming
at.
It is a ceiling now. app_tab_bar_cols takes the app, because the answer
depends on the terminal and not only on the config: held to the columns
left after a pane's collapse width, and to a third of the screen, and
narrowed rather than dropped. TAB_BAR_FIT (14) is the narrowest width this
code will invent on its own -- a config that asks for less is taken at its
word -- and 0 means there is no room for a sidebar at all, which is the one
case that still falls back on top.
Height counts too, which it did not before: a sidebar spends a row per tab,
so a wide but short terminal runs out of list before it runs out of screen,
and a tab with no row is not clipped, it is unreachable. tab_bar_min_rows
is that floor. Not when the top strip could not seat them either -- moving
them would gain nothing, so the side you configured stands.
All of it is re-derived per frame, like the layout, so a resize moves the
bar and nothing remembers that it did.
test_tab_bar also stops depending on where the suite is run from. An
unnamed tab borrows the basename of its focused pane's cwd, so eight checks
read `tests` under `make test` (which cds there) and `slosh` from the
repository root, where they failed. They are about padding, alignment and
the index, not about that derivation, so they name the tab first -- by id,
since `set-name` takes 0 for the focused *pane* only. 147 checks, green
from either directory.
| -rw-r--r-- | docs/panes.md | 22 | ||||
| -rw-r--r-- | docs/slosh.5 | 14 | ||||
| -rw-r--r-- | include/app_internal.h | 9 | ||||
| -rw-r--r-- | src/app_draw.c | 2 | ||||
| -rw-r--r-- | src/app_layout.c | 89 | ||||
| -rw-r--r-- | src/config.c | 6 | ||||
| -rw-r--r-- | tests/test_tab_bar.py | 131 |
7 files changed, 235 insertions, 38 deletions
diff --git a/docs/panes.md b/docs/panes.md index 57e3014..6a4b586 100644 --- a/docs/panes.md +++ b/docs/panes.md @@ -54,7 +54,7 @@ The strip of tabs sits along the top by default. It does not have to: ```kdl tab_bar_side "left" // or "right"; "top" is the default -tab_bar_width 18 // columns the sidebar takes +tab_bar_width 18 // columns the sidebar takes, at most tab_bar_index "right" // the tab number at the far end; "prefix" is 1:name tab_bar_padding 0 1 0 2 // air inside it, in cells: top right bottom left tab_bar_pad 0 // ...or just the top of that, the older name @@ -78,8 +78,24 @@ 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. +keeps the full width. + +**The bar fits itself to the terminal, and gives up the edge only when it has +to.** `tab_bar_width` is a ceiling rather than a reservation: the sidebar is +held to a third of the screen, so 18 columns is a margin beside a 90-column +terminal and narrows to 16 beside a 50-column one, with the panes keeping the +rest and never dropping below the width they collapse at. Only when even a +legible sidebar (14 columns, or whatever narrower width you asked for) will not +fit does the strip go back to the top row. + +Height counts too: a sidebar spends a +row per tab, so a wide but short terminal runs out of list before it runs out +of screen — and a tab with no row is not clipped, it is unreachable. When the +rows for the labels and the `+` are not there, the strip goes back on top, +where the same tabs lie along one row. Not when that row could not hold them +either: with more tabs than the top strip can seat, moving them gains nothing +and the side you configured stands. All of it is re-derived per frame from the +terminal's size, so a resize moves the bar and nothing remembers it did. **The sidebar is framed like a pane**, so it belongs to the chrome rather than floating beside it. Under [`compact`](config.md#compact) the frame's inner line diff --git a/docs/slosh.5 b/docs/slosh.5 index 3c1a43a..6226cf9 100644 --- a/docs/slosh.5 +++ b/docs/slosh.5 @@ -129,11 +129,17 @@ line's side margin. .TP .BR tab_bar_side\ \fIside\fR ,\ tab_bar_width\ \fIn\fR ,\ tab_bar_pad\ \fIn\fR Where the tab strip lives: \fBtop\fR (the default), or a sidebar on the -\fBleft\fR or \fBright\fR listing one tab per row, \fBtab_bar_width\fR -columns wide, with \fBtab_bar_pad\fR blank rows above the first tab. +\fBleft\fR or \fBright\fR listing one tab per row, at most +\fBtab_bar_width\fR columns wide, with \fBtab_bar_pad\fR blank rows above +the first tab. The strip's indicators move to the sidebar's bottom rows; the status line -keeps the full width. A terminal too narrow to give up the columns falls -back to \fBtop\fR until it grows. +keeps the full width. +The width is a ceiling, not a reservation: a sidebar narrows to fit a small +screen \(em never more than a third of it, never into the columns a pane +collapses at \(em and falls back to \fBtop\fR when what is left would be too +narrow to read a name in, or when the terminal is too short to give every tab +a row (unless the top strip could not seat them either, where nothing is +gained by moving them). Re-derived per frame, so a resize moves the bar. .TP .BI tab_bar_padding\ n\ ... Air inside a sidebar, between its frame and the list, per side and in diff --git a/include/app_internal.h b/include/app_internal.h index 40d169a..c6769d2 100644 --- a/include/app_internal.h +++ b/include/app_internal.h @@ -489,10 +489,13 @@ void walk_all(app_t *a, leaf_fn fn, void *ud); uint16_t eff_gap(split_dir_t dir); rect_t app_tab_area(app_t *a); /* Where the tab bar lands this frame (TAB_BAR_*), and how many columns a - * sidebar takes. The side is re-derived per frame: a screen too narrow for - * the sidebar falls back to the top row. */ + * sidebar takes. Both are re-derived per frame from the terminal's size: a + * sidebar narrows to fit a small screen, and one that can no longer seat + * either the panes or its own list of tabs falls back to the top row. The + * columns are 0 when there is no room for a sidebar at all; pass a NULL app + * to ask only what the config wants. */ int app_tab_bar_side(const app_t *a); -uint16_t app_tab_bar_cols(void); +uint16_t app_tab_bar_cols(const app_t *a); void close_leaf(app_t *a, node_t *leaf); node_t *first_leaf_of(node_t *n); void focus_dir(app_t *a, int dx, int dy); diff --git a/src/app_draw.c b/src/app_draw.c index b3d8f07..7545c66 100644 --- a/src/app_draw.c +++ b/src/app_draw.c @@ -2231,7 +2231,7 @@ static size_t wrap_cells(const char *text, uint16_t width, size_t max, 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; - uint16_t sw = app_tab_bar_cols(); + uint16_t sw = app_tab_bar_cols(a); uint16_t x = left ? 0 : (uint16_t)(s->cols > sw ? s->cols - sw : 0); uint16_t top = CFG.compact ? 0 : CFG.gap; /* the strip's own row rule */ uint16_t limit = (uint16_t)(s->rows > (CFG.status_line ? 1 : 0) diff --git a/src/app_layout.c b/src/app_layout.c index cf5b411..296727c 100644 --- a/src/app_layout.c +++ b/src/app_layout.c @@ -322,26 +322,87 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { #define STRIP_ROWS (CFG.status_bar ? 1 : 0) #define LINE_ROWS (CFG.status_line ? 1 : 0) -/* The sidebar's width, held to a range where it is neither an unreadable - * sliver nor half the screen. Clamped at use rather than at parse so a config - * written for a big terminal degrades on a small one instead of erroring. */ -uint16_t app_tab_bar_cols(void) { +/* The narrowest a sidebar is worth having, and the narrowest this code will + * shrink one to on its own. A config that asks for less than TAB_BAR_MIN is + * taken at its word -- someone who wrote `tab_bar_width 8` wants the sliver + * -- but a width nobody asked for is never invented below TAB_BAR_FIT, where + * " 12:name " stops fitting and the list is numbers and ellipses. */ +#define TAB_BAR_MIN 8 +#define TAB_BAR_FIT 14 +#define TAB_BAR_MAX 60 + +/* The sidebar's width this frame: what the config asked for, held to a range + * where it is neither an unreadable sliver nor half the screen, and then to + * the room this terminal actually has. Clamped at use rather than at parse, + * so a config written for a big terminal degrades on a small one instead of + * erroring -- and it degrades by narrowing first, because a sidebar three + * columns thinner is still the list you navigate by, while one that jumps to + * the top row moves every target you were aiming at. + * + * Two ceilings, both about the panes rather than about the bar: the columns + * left after the width a pane collapses at (plus the gap either side), and a + * third of the screen, whatever that arithmetic says. The second is the one + * that does the work on a small terminal -- 18 columns is a margin next to a + * 100-column screen and a sidecar next to a 50-column one, and the bar is + * chrome for the panes either way. + * + * Zero when what is left is narrower than the bar is worth: the caller reads + * that as "no room for a sidebar at all" and puts the strip back on top. */ +uint16_t app_tab_bar_cols(const app_t *a) { uint16_t w = CFG.tab_bar_width; - if (w < 8) w = 8; - if (w > 60) w = 60; - return w; + if (w < TAB_BAR_MIN) w = TAB_BAR_MIN; + if (w > TAB_BAR_MAX) w = TAB_BAR_MAX; + if (!a) return w; + + uint16_t spare = a->cols > (uint16_t)(CFG.min_pane_cols + 4) + ? (uint16_t)(a->cols - CFG.min_pane_cols - 4) + : 0; + uint16_t third = (uint16_t)(a->cols / 3); + if (spare > third) spare = third; + + /* The floor to shrink against: never wider than what was configured, so a + * deliberately narrow bar is not "grown" into a fallback it never asked + * for, and never narrower than a legible one. */ + uint16_t least = w < TAB_BAR_FIT ? w : TAB_BAR_FIT; + if (spare < least) return 0; + return w < spare ? w : spare; +} + +/* The rows a sidebar needs before it starts hiding doors: its own top air, + * the frame's two lines, a row per tab, the `+`, and the status line's row + * underneath. Everything else in there -- the gaps, the pads, the statuses -- + * already gives itself up a row at a time when the list is short of room, so + * it is the labels and the button that set the floor. */ +static uint16_t tab_bar_min_rows(const app_t *a) { + uint16_t need = (uint16_t)(CFG.compact ? 0 : CFG.gap); + if (CFG.tab_bar_chrome) need = (uint16_t)(need + 2); + if (CFG.newtab_button && CFG.newtab_mark[0]) need++; + if (CFG.status_line) need++; + return (uint16_t)(need + a->ntabs); } /* The side the strip actually lands on this frame. The configured side, - * except that a screen too narrow to give up the sidebar's columns falls - * back to the top row: a sidebar that leaves no room for a pane is chrome - * displacing the thing it is chrome for. Re-derived per frame like the - * layout itself, so a resize moves the bar without anyone storing state. */ + * except on a terminal too small to seat it, where it falls back to the top + * row: a sidebar that leaves no room for the panes -- or no room for its own + * list -- is chrome displacing the thing it is chrome for. Re-derived per + * frame like the layout itself, so a resize moves the bar without anyone + * storing state. + * + * Too narrow is app_tab_bar_cols' answer, after it has tried shrinking. Too + * short is this function's: the sidebar spends a row per tab, so a wide but + * short terminal runs out of list before it runs out of screen, and the tabs + * that fall off the bottom are not clipped but unreachable. The strip lays + * the same tabs along one row -- but only while that row can hold them, so + * the fallback is conditional on the strip actually being the better seat. + * Eight columns is about what a tab cell costs at its most squeezed; if the + * tabs would not fit there either, nothing is gained by moving them and the + * configured side stands. */ int app_tab_bar_side(const app_t *a) { int side = CFG.tab_bar_side; if (side == TAB_BAR_TOP || !CFG.status_bar) return TAB_BAR_TOP; - uint16_t sw = app_tab_bar_cols(); - if (a->cols < sw + CFG.min_pane_cols + 4) return TAB_BAR_TOP; + if (!app_tab_bar_cols(a)) return TAB_BAR_TOP; + if (a->rows < tab_bar_min_rows(a) && a->ntabs * 8 <= a->cols) + return TAB_BAR_TOP; return side; } @@ -429,7 +490,7 @@ static rect_t tab_area(app_t *a) { * way -- it keeps the full width (LINE_ROWS is subtracted from the height * alone, never from the sidebar's rows: the sidebar stops above it). */ int side = app_tab_bar_side(a); - uint16_t sw = side != TAB_BAR_TOP ? app_tab_bar_cols() : 0; + uint16_t sw = side != TAB_BAR_TOP ? app_tab_bar_cols(a) : 0; uint16_t strip = side == TAB_BAR_TOP ? STRIP_ROWS : 0; uint16_t top = (uint16_t)(gy + strip); uint16_t lx = (uint16_t)(gx + (side == TAB_BAR_LEFT ? sw : 0)); diff --git a/src/config.c b/src/config.c index 60eb711..b2f3243 100644 --- a/src/config.c +++ b/src/config.c @@ -997,7 +997,9 @@ void config_defaults(config_t *c) { c->tab_bar_side = TAB_BAR_TOP; c->tab_bar_index = TAB_INDEX_RIGHT; /* Wide enough for " 12:a-real-name " plus a bell; narrow enough that a - * 100-column terminal keeps a working layout beside it. */ + * 100-column terminal keeps a working layout beside it. A ceiling rather + * than a reservation -- app_tab_bar_cols narrows it to fit a smaller + * screen, so this is the width you get when there is room for it. */ c->tab_bar_width = 18; /* None by default: tab_bar_chrome's top line is the separation the air was * standing in for, and a blank row inside the frame on top of it reads as @@ -1771,7 +1773,7 @@ char *config_render(const config_t *c) { "tab_bar_index \"%s\" // the sidebar's number at its far edge, or " "1:name\n", c->tab_bar_index == TAB_INDEX_RIGHT ? "right" : "prefix"); - cb_add(&b, "tab_bar_width %u // columns a sidebar takes\n", + cb_add(&b, "tab_bar_width %u // columns a sidebar takes, at most\n", c->tab_bar_width); if (c->tab_pad_top == c->tab_pad_right && c->tab_pad_top == c->tab_pad_bottom && c->tab_pad_top == c->tab_pad_left) diff --git a/tests/test_tab_bar.py b/tests/test_tab_bar.py index 6a1a0a5..af5e37e 100644 --- a/tests/test_tab_bar.py +++ b/tests/test_tab_bar.py @@ -40,6 +40,22 @@ NT_PAD = 1 SH = ["/bin/sh", "-c", "stty raw -echo; cat"] +# An unnamed tab borrows the basename of its focused pane's cwd, so every row +# below would otherwise read differently depending on where the suite was run +# from -- `tests` under `make test`, `slosh` from the repository root. These +# checks are about padding, alignment and the index, not about that +# derivation, so they name the tab and assert against the name they gave it. +TAB = "tests" + + +def _named(s): + """Pin the label, so a row's contents do not depend on the cwd. + + By id, because `set-name` takes 0 for the focused *pane* only -- a tab is + named by naming a tab. + """ + s.api("set-name", id=s.tabs()[0]["id"], name=TAB) + def _row(snap, y): """The sidebar's content on a row, without the frame either side.""" @@ -171,6 +187,7 @@ def test_padding_is_the_air_inside_the_strip(): padded = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_padding 1 2\n') with Session(SH, cols=90, rows=20, config=padded) as s: s.settle(30) + _named(s) _status(s, "building") snap = s.snapshot() check( @@ -179,7 +196,7 @@ def test_padding_is_the_air_inside_the_strip(): repr(_row(snap, Y0)), ) label = _row(snap, Y0 + 1) - check("the label is indented by two", label.startswith(" tests"), repr(label)) + check("the label is indented by two", label.startswith(f" {TAB}"), repr(label)) check( "...and the number is held off the other edge", label.endswith("1 "), @@ -187,7 +204,7 @@ def test_padding_is_the_air_inside_the_strip(): ) check( "a status keeps the column its label starts in", - _row(snap, Y0 + 2).index("building") == label.index("tests"), + _row(snap, Y0 + 2).index("building") == label.index(TAB), repr(_row(snap, Y0 + 2)), ) @@ -198,11 +215,12 @@ def test_padding_zero_puts_it_against_the_frame(): flush = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_padding 0\n') with Session(SH, cols=90, rows=20, config=flush) as s: s.settle(30) + _named(s) _status(s, "building") snap = s.snapshot() check( "the name starts in the first column", - _row(snap, Y0).startswith("tests"), + _row(snap, Y0).startswith(TAB), repr(_row(snap, Y0)), ) check( @@ -245,10 +263,11 @@ def test_tab_bar_pad_is_still_the_top_of_it(): ) with Session(SH, cols=90, rows=20, config=both) as s: s.settle(30) + _named(s) snap = s.snapshot() check( "one row of air, not four", - not _row(snap, Y0).strip() and "tests" in _row(snap, Y0 + 1), + not _row(snap, Y0).strip() and TAB in _row(snap, Y0 + 1), f"{_row(snap, Y0)!r} / {_row(snap, Y0 + 1)!r}", ) @@ -270,6 +289,7 @@ def test_the_index_sits_at_the_far_end(): it.""" with Session(SH, cols=90, rows=20, config=RIGHT_IDX) as s: s.settle(30) + _named(s) s.api("new-tab", name="api") s.settle(30) rows = [_row(snap := s.snapshot(), Y0), _row(snap, Y0 + 1)] @@ -280,7 +300,7 @@ def test_the_index_sits_at_the_far_end(): ) check( "...and every name starts in the same column", - rows[1].index("api") == rows[0].index("tests"), + rows[1].index("api") == rows[0].index(TAB), str(rows), ) check("no prefix is left behind", "1:" not in rows[0], str(rows)) @@ -320,9 +340,10 @@ def test_the_top_strip_ignores_it(): top = _cfg('tab_bar_index "right"\n') with Session(SH, cols=60, rows=10, config=top) as s: s.settle(30) + _named(s) check( "the strip still writes 1:name", - "1:tests" in s.snapshot().screen(), + f"1:{TAB}" in s.snapshot().screen(), s.snapshot().line(1), ) @@ -353,8 +374,13 @@ def test_the_prefix_is_still_there_for_anyone_who_wants_it(): before the alignment existed.""" with Session(SH, cols=90, rows=20, config=PREFIX_IDX) as s: s.settle(30) + _named(s) row = _row(s.snapshot(), Y0) - check("the number is in front of the name", row.strip() == "1:tests", repr(row)) + check( + "the number is in front of the name", + row.strip() == f"1:{TAB}", + repr(row), + ) def test_the_sidebar_aligns_by_default(): @@ -476,6 +502,7 @@ def test_status_rows_sit_under_their_tab(): def test_status_rows_are_told_apart_by_the_slant_not_by_an_indent(): with Session(SH, cols=90, rows=20, config=LEFT) as s: s.settle(30) + _named(s) _status(s, "building 3/7") snap = s.snapshot() label, status = _row(snap, Y0), _row(snap, Y0 + 1) @@ -483,8 +510,8 @@ def test_status_rows_are_told_apart_by_the_slant_not_by_an_indent(): # starts in. Three columns of indent is a fifth of this sidebar. check( "a status is not indented under its label", - status.index("building") == label.index("tests"), - f"{status.index('building')} vs {label.index('tests')}", + status.index("building") == label.index(TAB), + f"{status.index('building')} vs {label.index(TAB)}", ) st = snap.style_at(CX + 1, Y0 + 1) check("it is italic instead", "italic" in st["attrs"], str(st)) @@ -1271,9 +1298,88 @@ def test_the_strip_keeps_its_bare_mark(): ) +def test_a_small_screen_narrows_the_sidebar_first(): + """Eighteen columns is a margin beside a ninety-column screen and a sidecar + beside a fifty-column one, so the width is a ceiling rather than a + reservation: the bar hands columns back before it hands back the edge, + keeping to a third of the screen so the panes always have the rest.""" + with Session(SH, cols=50, rows=20, config=LEFT) as s: + s.settle(30) + snap = s.snapshot() + check( + "the sidebar kept its edge", + snap.hit_at(CX, Y0) == "tab:1", + str(snap.hit_at(CX, Y0)), + ) + # A pane's x is its text, which starts after the gap, the bar, and the + # pane's own left border: the bar's width is that minus the two. + sw = s.pane(0)["x"] - 2 + check("...by narrowing, not by moving", sw < W, str(sw)) + check("no more than a third of the screen", sw <= 50 // 3, str(sw)) + check("and still wide enough to read a name in", sw >= 14, str(sw)) + s.resize(90, 20) + s.until(lambda _: s.pane(0)["x"] - 2 == W) + check( + "the room comes back and so does the width", + s.pane(0)["x"] - 2 == W, + str(s.pane(0)["x"] - 2), + ) + + +def test_a_short_terminal_falls_back_to_top(): + """The sidebar spends a row per tab, so a wide but short terminal runs out + of list before it runs out of screen -- and a tab with no row is not + clipped, it is unreachable. The strip lays the same tabs along one row.""" + with Session(SH, cols=90, rows=10, config=LEFT) as s: + s.settle(30) + _named(s) + check( + "one tab fits down the edge", + s.snapshot().hit_at(CX, Y0) == "tab:1", + str(s.snapshot().hit_at(CX, Y0)), + ) + # Named, like the first one, because the last check needs all seven to + # fit *across* the strip: unnamed, they are labelled from the cwd, and + # a checkout called something longer than `slosh` pushes the seventh + # tab off the row and out of the hit map. The fallback itself is about + # rows, so the names do not affect what is being tested. + for i in range(6): + s.api("new-tab", name=f"t{i}") + s.settle(20) + s.until(lambda snap: snap.hit_at(5, 1) == "tab:1") + snap = s.snapshot() + check( + "seven of them do not, so the strip goes back on top", + snap.hit_at(5, 1) == "tab:1", + str(snap.hit_at(5, 1)), + ) + 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("and every tab is reachable again", not missing, str(missing)) + + +def test_a_strip_that_could_not_hold_them_either_changes_nothing(): + """The fallback is only worth making while the top row is the better seat. + Tabs too many for one row are clipped there as surely as they are cut off + the bottom of a sidebar -- and the sidebar at least shows more of them -- + so the configured side stands.""" + with Session(SH, cols=60, rows=10, config=LEFT) as s: + s.settle(30) + for _ in range(9): + s.api("new-tab") + s.settle(20) + snap = s.snapshot() + check( + "the sidebar keeps the edge it was given", + snap.hit_at(CX, Y0) == "tab:1", + str(snap.hit_at(CX, Y0)), + ) + + def test_narrow_terminal_falls_back_to_top(): - # 40 < width + min_pane cols + 4: the sidebar would leave no room for the - # pane it is chrome for, so the strip goes back to the top row. + # 40 columns: a third of it is 13, narrower than a sidebar is worth, and + # what is left would not seat the pane the bar is chrome for either. So + # the strip goes back to the top row rather than shrink any further. with Session(SH, cols=40, rows=20, config=LEFT) as s: s.settle(30) snap = s.snapshot() @@ -1355,6 +1461,9 @@ if __name__ == "__main__": test_wrapping_still_says_when_a_status_did_not_fit() test_a_narrow_sidebar_keeps_the_bare_mark() test_the_strip_keeps_its_bare_mark() + test_a_small_screen_narrows_the_sidebar_first() + test_a_short_terminal_falls_back_to_top() + test_a_strip_that_could_not_hold_them_either_changes_nothing() test_narrow_terminal_falls_back_to_top() test_growing_back_restores_the_sidebar() sys.exit(report()) |
