1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
|
#!/usr/bin/env python3
"""The tab bar on its side: tab_bar_side left / right.
A sidebar lists one tab per row down the chosen edge and the panes give up
its columns instead of the top row. The strip's indicators (pane count, the
prefix badge) move to the sidebar's bottom rows, the `+` sits on the row
after the last tab, and every mouse verb keeps working because the hits move
with the cells. A terminal too narrow to give up the columns falls back to
the top strip until it grows.
"""
import sys
import tempfile
from harness import Session, check, report
def _cfg(text):
f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
f.write(text)
f.close()
return f.name
W = 18
LEFT = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\n')
RIGHT = _cfg(f'tab_bar_side "right"\ntab_bar_width {W}\n')
BARE = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_chrome false\n')
# With tab_bar_chrome (the default) the frame takes a column each side and a
# row top and bottom: content runs x 1..16, and gap 1 + frame 1 + pad 0 puts
# the first tab on row 2. CX/CW/Y0 are that content box, so a geometry change
# is one edit here rather than thirty coordinates below.
CX, CW, Y0 = 1, W - 2, 2
SH = ["/bin/sh", "-c", "stty raw -echo; cat"]
def _row(snap, y):
"""The sidebar's content on a row, without the frame either side."""
return snap.line(y)[CX : CX + CW]
def test_left_sidebar_reserves_columns():
with Session(SH, cols=90, rows=20, config=LEFT) as s:
s.settle(30)
snap = s.snapshot()
check(
"the first tab is a row in the sidebar",
snap.hit_at(CX, Y0) == "tab:1",
str(snap.hit_at(CX, Y0)),
)
check(
"the whole row is the target, not just the label",
snap.hit_at(CX + CW - 1, Y0) == "tab:1",
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",
str(snap.hit_at(CX, Y0 + 1)),
)
p = s.pane(0)
check("panes give up the sidebar's columns", p["x"] >= W, str(p["x"]))
# rows 20, status line on 19, the frame's bottom line on 18.
check(
"the pane count moved to the sidebar's bottom",
"1 pane" in _row(snap, 17),
repr(_row(snap, 17)),
)
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
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()))
snap = s.snapshot()
check(
"the new tab is the next row down",
snap.hit_at(CX, Y0 + 1) == "tab:2",
str(snap.hit_at(CX, Y0 + 1)),
)
s.click(CX, Y0) # the first tab's row
s.until(lambda _: s.tabs()[0]["active"])
check("clicking a row selects that tab", s.tabs()[0]["active"], str(s.tabs()))
def test_right_sidebar_takes_the_other_edge():
with Session(SH, cols=90, rows=20, config=RIGHT) as s:
s.settle(30)
snap = s.snapshot()
x0 = 90 - W
check(
"the first tab is a row on the right edge",
snap.hit_at(x0 + CX, Y0) == "tab:1",
str(snap.hit_at(x0 + CX, Y0)),
)
p = s.pane(0)
check(
"panes stop where the sidebar starts",
p["x"] + p["w"] <= x0,
f"{p['x']}+{p['w']} vs {x0}",
)
def test_pad_pushes_the_list_down():
# The default pad is 0, so Y0 is the unpadded first row and a pad of N
# moves the list exactly N rows down it.
n = 3
pad = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_pad {n}\n')
with Session(SH, cols=90, rows=20, config=pad) as s:
s.settle(30)
snap = s.snapshot()
check(
"the pad rows are not targets",
all(snap.hit_at(CX, Y0 + k) is None for k in range(n)),
str([snap.hit_at(CX, Y0 + k) for k in range(n)]),
)
check(
"the first tab starts below the pad",
snap.hit_at(CX, Y0 + n) == "tab:1",
str(snap.hit_at(CX, Y0 + n)),
)
p = s.pane(0)
check(
"the pad is paint, not layout: panes keep their rows",
p["y"] <= 2,
str(p["y"]),
)
def _status(s, text):
s.send(rf"\e]5577;1;status;{text}\e\\")
s.settle(30)
def test_status_rows_sit_under_their_tab():
with Session(SH, cols=90, rows=20, config=LEFT) as s:
s.settle(30)
pane = s.pane(0)["id"]
_status(s, "building 3/7")
s.api("new-tab")
s.settle(30)
snap = s.snapshot()
check(
"a pane's OSC status is a row under its tab",
"building 3/7" in _row(snap, Y0 + 1),
repr(_row(snap, Y0 + 1)),
)
check(
"the other tab moved down to make room",
snap.hit_at(CX, Y0 + 2) == "tab:2",
str(snap.hit_at(CX, Y0 + 2)),
)
check(
"the row is a door to the pane that said it",
snap.hit_at(CX + 1, Y0 + 1) == f"find:{pane}",
str(snap.hit_at(CX + 1, Y0 + 1)),
)
# We are in tab 2: clicking the status row is a cross-tab jump.
s.click(CX + 1, Y0 + 1)
s.until(lambda _: s.tabs()[0]["active"])
check("clicking it selects that tab", s.tabs()[0]["active"], str(s.tabs()))
check(
"...and focuses that pane",
s.focused()["id"] == pane,
str(s.focused()["id"]),
)
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)
_status(s, "building 3/7")
snap = s.snapshot()
label, status = _row(snap, Y0), _row(snap, Y0 + 1)
# One leading space each: a status starts in the column its tab's name
# 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("1:"),
f"{status.index('building')} vs {label.index('1:')}",
)
st = snap.style_at(CX + 1, Y0 + 1)
check("it is italic instead", "italic" in st["attrs"], str(st))
check(
"...and the label above it is not",
"italic" not in snap.style_at(CX + 1, Y0)["attrs"],
str(snap.style_at(CX + 1, Y0)),
)
def test_status_rows_take_their_own_theme_colours():
themed = _cfg(
f'tab_bar_side "left"\ntab_bar_width {W}\n'
'theme { tab_status_fg "#00ff88"\n tab_status_bg "#202030" }\n'
)
with Session(SH, cols=90, rows=20, config=themed) as s:
s.settle(30)
_status(s, "building")
snap = s.snapshot()
st = snap.style_at(CX + 1, Y0 + 1)
check("the fg is the themed one", st["fg"] == "#00ff88", str(st))
end = snap.style_at(CX + CW - 1, Y0 + 1)
check(
"and the band covers the row, not just the words",
end and end["bg"] == "#202030",
str(end),
)
def test_an_old_theme_gets_coherent_status_colours_anyway():
"""A theme written before these rows existed names tab_count and nothing
about them; deriving from it beats one stock grey among its own greys."""
old = _cfg(
f'tab_bar_side "left"\ntab_bar_width {W}\ntheme {{ tab_count "#ff8800" }}\n'
)
with Session(SH, cols=90, rows=20, config=old) as s:
s.settle(30)
_status(s, "building")
st = s.snapshot().style_at(CX + 1, Y0 + 1)
check("the status follows tab_count", st["fg"] == "#ff8800", str(st))
def test_status_cap_spends_its_last_row_on_the_ellipsis():
capped = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\ntab_bar_status 1\n')
with Session(SH, cols=90, rows=20, config=capped) as s:
s.settle(30)
_status(s, "first")
s.api("split", dir="cols")
s.settle(30)
_status(s, "second")
snap = s.snapshot()
check(
"over the cap, the row says there was more",
_row(snap, Y0 + 1).strip() == "\u2026",
repr(_row(snap, Y0 + 1)),
)
check(
"an ellipsis is not a door",
snap.hit_at(CX + 1, Y0 + 1) is None,
str(snap.hit_at(CX + 1, Y0 + 1)),
)
check(
"and only the cap's rows were spent",
snap.hit_at(CX, Y0 + 2) == "newtab",
str(snap.hit_at(CX, Y0 + 2)),
)
def test_a_long_status_is_cut_and_says_so():
with Session(SH, cols=90, rows=20, config=LEFT) as s:
s.settle(30)
_status(s, "a status far too long for the sidebar")
row = _row(s.snapshot(), Y0 + 1)
check(
"the status stops at the sidebar's edge", row.endswith("\u2026"), repr(row)
)
def test_a_dead_pane_reports_its_exit_instead():
# Wider than the default: "exited: status 3" plus the indent is 19 cells,
# and the point here is the words, not the (already tested) truncation.
kept = _cfg('tab_bar_side "left"\ntab_bar_width 24\nkeep_dead "all"\n')
with Session(["/bin/sh", "-c", "exit 3"], cols=90, rows=20, config=kept) as s:
s.until(lambda snap: "exited" in snap.line(Y0 + 1))
row = s.snapshot().line(Y0 + 1)[CX : CX + 22]
check("how it died replaces what it last said", "status 3" in row, repr(row))
def test_an_unnamed_tab_borrows_its_directory():
cd = ["/bin/sh", "-c", "cd /tmp && stty raw -echo; cat"]
with Session(cd, cols=90, rows=20, config=LEFT) as s:
s.settle(60)
row = _row(s.snapshot(), Y0)
check("the label is the focused pane's dir", "1:tmp" in row, repr(row))
def test_a_real_name_beats_the_derived_one():
lay = tempfile.NamedTemporaryFile("w", suffix=".layout", delete=False)
lay.write('layout {\n tab name="api" {\n pane\n }\n}\n')
lay.close()
with Session(SH, cols=90, rows=20, config=LEFT, layout=lay.name) as s:
s.settle(60)
row = _row(s.snapshot(), Y0)
check("a declared name is not second-guessed", "1:api" in row, repr(row))
def test_the_borrowed_name_follows_a_cd():
loop = ["/bin/sh", "-c", 'cd /tmp && while IFS= read -r l; do eval "$l"; done']
with Session(loop, cols=90, rows=20, config=LEFT) as s:
s.until(lambda snap: "1:tmp" in snap.line(Y0))
s.send(r"cd /home\n")
s.until(lambda snap: "1:home" in snap.line(Y0))
row = _row(s.snapshot(), Y0)
check("the kernel's answer moves the label", "1:home" in row, repr(row))
def test_the_sidebar_is_framed_like_a_pane():
with Session(SH, cols=90, rows=20, config=LEFT) as s:
s.settle(40)
snap = s.snapshot()
top, bottom = snap.line(1), snap.line(18)
check("a frame runs round the list", top[0] in "\u256d\u250c", repr(top[:W]))
check(
"...closed at the bottom, above the status line",
bottom[0] in "\u2570\u2514",
repr(bottom[:W]),
)
check(
"and the list sits inside it",
snap.line(Y0)[0] == "\u2502" and snap.line(Y0)[CX + CW] == "\u2502",
repr(snap.line(Y0)[:W]),
)
def test_compact_shares_its_lines_with_the_ring():
"""The point of the chrome: one figure, not a box beside a box.
The sidebar's inner vertical *is* the tab area's ring column, so the
stroke union turns the ring's top-left corner into a tee -- nobody
computes that, it falls out of the same machinery the dividers use.
"""
conf = _cfg(f'compact true\ntab_bar_side "left"\ntab_bar_width {W}\n')
with Session(SH, cols=90, rows=20, config=conf) as s:
s.settle(40)
snap = s.snapshot()
check(
"the sidebar meets the ring in a junction, not a corner",
snap.line(0)[W] == "\u252c",
repr(snap.line(0)[: W + 2]),
)
check(
"...and again at the bottom",
snap.line(18)[W] == "\u2534",
repr(snap.line(18)[: W + 2]),
)
def test_chrome_false_gives_the_columns_back():
with Session(SH, cols=90, rows=20, config=BARE) as s:
s.settle(40)
snap = s.snapshot()
# No frame row either, so the list starts on the gap's own row.
check(
"no frame: the list starts in the first column",
snap.hit_at(0, 1) == "tab:1",
str(snap.hit_at(0, 1)),
)
check(
"and the whole width is the target",
snap.hit_at(W - 1, 1) == "tab:1",
str(snap.hit_at(W - 1, 1)),
)
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)
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)),
)
def test_a_narrow_sidebar_keeps_the_bare_mark():
narrow = _cfg('tab_bar_side "left"\ntab_bar_width 10\n')
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]
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)),
)
def test_the_strip_keeps_its_bare_mark():
"""The word is a sidebar's affordance, not a new spelling everywhere: in
the strip the mark sits at the end of a row of tabs, where columns are
scarce and the company it keeps says what it is."""
with Session(SH, cols=90, rows=20) as s:
s.settle(40)
check(
"the strip is unchanged",
"new tab" not in s.snapshot().line(1),
repr(s.snapshot().line(1)),
)
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.
with Session(SH, cols=40, rows=20, config=LEFT) as s:
s.settle(30)
snap = s.snapshot()
check(
"the strip is a top row again",
snap.hit_at(5, 1) == "tab:1",
str(snap.hit_at(5, 1)),
)
p = s.pane(0)
check("and the panes keep the columns", p["x"] < W, str(p["x"]))
def test_growing_back_restores_the_sidebar():
with Session(SH, cols=40, rows=20, config=LEFT) as s:
s.settle(30)
s.resize(90, 20)
s.until(lambda snap: snap.hit_at(CX, Y0) == "tab:1")
check(
"a resize moves the bar without anyone storing state",
s.snapshot().hit_at(CX, Y0) == "tab:1",
str(s.snapshot().hit_at(CX, Y0)),
)
p = s.pane(0)
check("and the panes give the columns back", p["x"] >= W, str(p["x"]))
if __name__ == "__main__":
test_left_sidebar_reserves_columns()
test_sidebar_rows_click_like_the_strip()
test_right_sidebar_takes_the_other_edge()
test_pad_pushes_the_list_down()
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()
test_an_old_theme_gets_coherent_status_colours_anyway()
test_status_cap_spends_its_last_row_on_the_ellipsis()
test_a_long_status_is_cut_and_says_so()
test_a_dead_pane_reports_its_exit_instead()
test_an_unnamed_tab_borrows_its_directory()
test_a_real_name_beats_the_derived_one()
test_the_borrowed_name_follows_a_cd()
test_the_sidebar_is_framed_like_a_pane()
test_compact_shares_its_lines_with_the_ring()
test_chrome_false_gives_the_columns_back()
test_the_sidebar_button_says_what_it_does()
test_a_narrow_sidebar_keeps_the_bare_mark()
test_the_strip_keeps_its_bare_mark()
test_narrow_terminal_falls_back_to_top()
test_growing_back_restores_the_sidebar()
sys.exit(report())
|