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
|
#!/usr/bin/env python3
"""M5: the status bar and the pane finder.
Tabs stop being navigation somewhere around six projects; the finder is what
replaces them at that scale.
"""
import sys
from harness import Session, check, report
SH = ["/bin/sh", "-c", 'printf "\\033]2;shell\\007"; stty raw -echo; cat']
def test_status_bar():
with Session(SH, cols=70, rows=14) as s:
s.settle()
snap = s.snapshot()
bar = snap.line(1)
check("the bar shows a pane count", "1 pane" in bar, repr(bar))
# Found through the hit list rather than by its label: what the
# button says is the config's business (`newtab_mark`), and a test
# that spells it out fails the day somebody changes their mind.
btn = [h for h in snap.hits if h["action"] == "newtab"]
check("the bar shows a new-tab button", len(btn) == 1, str(snap.hits))
x = btn[0]["x"] + 1
check(
"the new-tab button is clickable",
snap.hit_at(x, 1) == "newtab",
str(snap.hit_at(x, 1)),
)
s.click(x, 1)
s.settle()
check("clicking + makes a tab", len(s.tabs()) == 2, str(s.tabs()))
s.key("\\\\")
s.settle()
check(
"the pane count follows",
"3 panes" in s.snapshot().line(1),
repr(s.snapshot().line(1)),
)
with Session(SH, cols=70, rows=14) as s:
s.settle()
s.send(r"\x01") # prefix, and then nothing
s.settle(60)
check(
"a held prefix is shown as a mode",
"C-a" in s.snapshot().line(1),
repr(s.snapshot().line(1)),
)
s.send("q") # release it without quitting the session... 'q' quits
s.settle(60)
def setup_panes(s):
"""Three panes with distinguishable purposes across two tabs."""
s.settle()
a = s.pane()["id"]
s.api("set-purpose", target="pane", id=a, purpose="agent:main", declared=True)
r = s.api("new-tab", name="build")
b = s.panes()[-1]["id"]
s.api("set-purpose", target="pane", id=b, purpose="service:web", declared=True)
s.settle()
return a, b
# The finder is a modal now, so "is it open" is asked the way it is asked of
# any modal: it owns rows in the hit list. Not by looking for a word on the
# screen, which is what the old tests did and which broke the moment the box
# got a title instead of a "find:" prompt.
def is_open(s):
snap = s.snapshot()
return any(
h["action"].startswith("find:") or h["action"] == "closefind" for h in snap.hits
)
def counter(s):
"""(selected, total) from the footer, or None when it is not shown."""
import re
for line in s.snapshot().screen().split("\n"):
m = re.search(r"(\d+) of (\d+)", line)
if m:
return int(m.group(1)), int(m.group(2))
return None
def rows(s):
"""The finder's result rows, top to bottom, as (action, y, text)."""
snap = s.snapshot()
out = []
for h in snap.hits:
if h["action"].startswith("find:"):
out.append((h["action"], h["y"], snap.text[h["y"]]))
return sorted(out, key=lambda r: r[1])
def test_finder_keyboard():
with Session(SH, cols=70, rows=16) as s:
a, b = setup_panes(s)
s.key("s")
s.settle(80)
snap = s.snapshot()
check("the finder opens", is_open(s), repr(snap.screen()[:200]))
check(
"it lists panes from every tab",
"agent:main" in snap.screen() and "service:web" in snap.screen(),
repr(snap.screen()),
)
check("and says how many it found", counter(s) == (1, 2), str(counter(s)))
s.send("web")
s.settle(80)
snap = s.snapshot()
check(
"typing filters the list",
"service:web" in snap.screen() and "agent:main" not in snap.screen(),
repr(snap.screen()),
)
check(
"the query is shown as you type",
"web" in snap.screen(),
repr(snap.screen()),
)
check("and the count follows it", counter(s) == (1, 1), str(counter(s)))
s.send(r"\r")
s.settle(80)
check("enter focuses the match", s.focused()["id"] == b, str(s.focused()))
check("and closes the finder", not is_open(s))
with Session(SH, cols=70, rows=16) as s:
setup_panes(s)
s.key("s")
s.send("zzzz")
s.settle(80)
check("a query matching nothing is not fatal", is_open(s))
check(
"and says so rather than showing an empty box",
"no pane matches" in s.snapshot().screen(),
repr(s.snapshot().screen()[:400]),
)
s.send(r"\x7f\x7f\x7f\x7f") # backspace back to empty
s.settle(80)
check(
"backspace restores the list",
"agent:main" in s.snapshot().screen(),
repr(s.snapshot().screen()[:200]),
)
s.send(r"\e")
s.settle(120)
check("escape closes it", not is_open(s))
check("and the session is unharmed", s.api("alive")["alive"])
def test_finder_navigation():
"""Arrows, the emacs pair, tab, and the ends -- a picker in this shape is
reached for by hands that already know one."""
with Session(SH, cols=70, rows=16) as s:
setup_panes(s)
s.key("s")
s.settle(80)
check("it starts on the first row", counter(s) == (1, 2), str(counter(s)))
for keys, want, what in (
(r"\e[B", 2, "down"),
(r"\e[A", 1, "up"),
(r"\x0e", 2, "C-n"),
(r"\x10", 1, "C-p"),
(r"\t", 2, "tab"),
(r"\e[Z", 1, "shift-tab"),
(r"\e[F", 2, "end"),
(r"\e[H", 1, "home"),
):
s.send(keys)
s.settle(60)
check(
f"{what} moves the selection",
counter(s) == (want, 2),
f"{what}: {counter(s)}",
)
# Wrapping: one press to reach the last row, which is most of why a
# short list wraps at all.
s.send(r"\e[A")
s.settle(60)
check(
"up from the first row wraps to the last",
counter(s) == (2, 2),
str(counter(s)),
)
s.send(r"\e[B")
s.settle(60)
check(
"and down from the last wraps to the first",
counter(s) == (1, 2),
str(counter(s)),
)
def test_finder_query_editing():
with Session(SH, cols=70, rows=16) as s:
setup_panes(s)
s.key("s")
s.send("web")
s.settle(80)
check("the query narrows to one", counter(s) == (1, 1), str(counter(s)))
s.send(r"\x15") # C-u
s.settle(80)
check("C-u clears the query rather than the finder", is_open(s))
check("and the whole list is back", counter(s) == (1, 2), str(counter(s)))
# A character, not a byte. The rename editor learned this first; the
# finder was still deleting one byte of a multi-byte character and
# leaving the rest.
s.send(r"\xc3\xa9") # e-acute, two bytes of UTF-8
s.settle(60)
check(
"a multi-byte character is accepted",
"\u00e9" in s.snapshot().screen(),
repr(s.snapshot().screen()[:300]),
)
s.send(r"\x7f")
s.settle(60)
check(
"and one backspace removes all of it",
"\u00e9" not in s.snapshot().screen() and counter(s) == (1, 2),
repr(s.snapshot().screen()[:300]),
)
def test_finder_mouse():
with Session(SH, cols=70, rows=16) as s:
a, b = setup_panes(s)
s.api("select-tab", index=1)
s.settle()
s.key("s")
s.settle(80)
snap = s.snapshot()
pos = snap.find("service:web")
check("the other tab's pane is listed", pos is not None, repr(snap.screen()))
if not pos:
return
x, y = pos
check(
"finder rows sit on top of the hit list",
snap.hit_at(x, y) == f"find:{b}",
str(snap.hit_at(x, y)),
)
s.click(x, y)
s.settle(80)
check(
"clicking a row focuses that pane", s.focused()["id"] == b, str(s.focused())
)
check("and switches to its tab", s.tabs()[1]["active"], str(s.tabs()))
def test_finder_dismissal_by_mouse():
"""A modal owns the pointer. Clicking past it must not also land on the
layout behind -- that is how you focus the pane you were clicking away
from."""
with Session(SH, cols=70, rows=16) as s:
a, b = setup_panes(s)
s.api("focus", id=b)
s.settle()
s.key("s")
s.settle(80)
close = [h for h in s.snapshot().hits if h["action"] == "closefind"]
check(
"it has a close button, like any modal",
len(close) == 1,
str(s.snapshot().hits),
)
if close:
s.click(close[0]["x"], close[0]["y"])
s.settle(80)
check("which closes it", not is_open(s))
check(
"without changing the focus", s.focused()["id"] == b, str(s.focused())
)
s.key("s")
s.settle(80)
check("it is open again", is_open(s))
s.click(1, 15) # the corner of the screen, well outside the box
s.settle(80)
check("a click outside dismisses it", not is_open(s))
check(
"and does not reach the pane behind it",
s.focused()["id"] == b,
str(s.focused()),
)
def test_finder_marks_where_you_are():
with Session(SH, cols=70, rows=16) as s:
a, b = setup_panes(s)
s.api("focus", id=b)
s.settle()
s.key("s")
s.settle(80)
marked = [r for r in rows(s) if "\u2022" in r[2]]
check(
"exactly one row is marked as the current pane",
len(marked) == 1,
str(rows(s)),
)
if marked:
check(
"and it is the pane we are in", marked[0][0] == f"find:{b}", str(marked)
)
def test_finder_over_a_collapsed_layout():
"""The finder is how you reach a pane a small screen has collapsed away."""
with Session(SH, cols=120, rows=32) as s:
s.settle()
s.key("\\\\")
s.settle()
s.key("\\\\")
s.settle()
target = s.panes()[0]["id"]
s.api(
"set-purpose",
target="pane",
id=target,
purpose="agent:findme",
declared=True,
)
s.api("focus", id=s.panes()[-1]["id"])
s.resize(50, 14)
s.settle()
check(
"the target is collapsed away",
[p for p in s.panes() if p["id"] == target][0]["hidden"],
)
s.key("s")
s.send("findme")
s.settle(80)
s.send(r"\r")
s.settle(80)
check(
"the finder reaches a collapsed pane and expands it",
not [p for p in s.panes() if p["id"] == target][0]["hidden"],
str(s.panes()),
)
if __name__ == "__main__":
test_status_bar()
test_finder_keyboard()
test_finder_navigation()
test_finder_query_editing()
test_finder_mouse()
test_finder_dismissal_by_mouse()
test_finder_marks_where_you_are()
test_finder_over_a_collapsed_layout()
sys.exit(report())
|