summaryrefslogtreecommitdiffstats
path: root/tests/test_responsive.py
blob: fe88d4d1b54f69a108e73fb12ffc2a16de682db7 (plain) (blame)
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
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
#!/usr/bin/env python3
"""M5 / D6: responsive collapse as a pure function of the rect.

The fork this replaces had a bug where a pane added after a narrow->wide cycle
was absorbed into a stack at full width, because feasibility was checked
against a pane count that a stack lies about. Here the layout is recomputed
from the tree and the rect every frame, so the equivalent bug has nowhere to
live — and these tests are what says so.
"""

import os
import sys
import tempfile

from harness import Session, check, report

SH = ["/bin/sh", "-c", 'printf "\\033]2;pane\\007"; stty raw -echo; cat']


def grid(s, n=4):
    """n panes in a mixed split, the arrangement that hurts on a small screen."""
    s.settle()
    s.key("\\\\")
    s.settle()
    s.key("\\\\")
    s.settle()
    s.key("-")
    s.settle()
    return s.panes()


def invariants(s, label):
    panes = [p for p in s.panes() if not p["hidden"]]
    snap = s.snapshot()
    rs = [(p["x"], p["y"], p["w"], p["h"]) for p in panes]
    for i in range(len(rs)):
        ax, ay, aw, ah = rs[i]
        if ax < 0 or ay < 0 or ax + aw > snap.cols or ay + ah > snap.rows:
            check(f"{label}: every visible pane is on screen", False, str(rs[i]))
            return panes
        for j in range(i + 1, len(rs)):
            bx, by, bw, bh = rs[j]
            if not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay):
                check(
                    f"{label}: visible panes do not overlap",
                    False,
                    f"{rs[i]} vs {rs[j]}",
                )
                return panes
    check(f"{label}: {len(rs)} visible, no overlap, all on screen", True)
    return panes


def test_collapse():
    with Session(SH, cols=120, rows=32) as s:
        grid(s)
        check(
            "nothing is hidden when there is room",
            not any(p["hidden"] for p in s.panes()),
            str(s.panes()),
        )
        invariants(s, "roomy")

        s.resize(56, 16)
        s.settle()
        panes = s.panes()
        check(
            "a squeezed layout collapses instead of shrinking to nothing",
            any(p["hidden"] for p in panes),
            str([p["hidden"] for p in panes]),
        )
        invariants(s, "squeezed")

        snap = s.snapshot()
        check(
            "the focused pane keeps a usable size",
            [p for p in panes if p["focused"]][0]["content_h"] >= 3,
            str([p for p in panes if p["focused"]]),
        )
        hidden = [q for q in s.panes() if q["hidden"]]
        tops = sum(1 for row in snap.text if row.strip().startswith("\u256d"))
        check(
            "every collapsed pane is drawn as the top edge of a pane",
            tops == len(hidden) + 1,  # the headers, plus the open pane
            f"{tops} tops for {len(hidden)} hidden: " + repr(snap.screen()),
        )


def test_collapse_expand_cycle():
    """The fork's bug, made unrepresentable: state cannot go stale if there is none."""
    with Session(SH, cols=120, rows=32) as s:
        before = [(p["x"], p["y"], p["w"], p["h"]) for p in grid(s)]

        s.resize(50, 14)
        s.settle()
        check("narrow: something collapsed", any(p["hidden"] for p in s.panes()))

        s.resize(120, 32)
        s.settle()
        after = [(p["x"], p["y"], p["w"], p["h"]) for p in s.panes()]
        check(
            "wide again: nothing is hidden",
            not any(p["hidden"] for p in s.panes()),
            str(s.panes()),
        )
        check(
            "the layout is exactly what it was before the cycle",
            before == after,
            f"{before}\n     != {after}",
        )

        # the fork's actual symptom: a pane added after the cycle got stacked
        s.key("-")
        s.settle()
        panes = invariants(s, "after the cycle")
        check(
            "a pane added after a narrow/wide cycle is not stacked",
            len(panes) == 5 and not any(p["hidden"] for p in s.panes()),
            str([(p["id"], p["hidden"]) for p in s.panes()]),
        )


def test_focus_expands():
    with Session(SH, cols=120, rows=32) as s:
        grid(s)
        s.resize(50, 14)
        s.settle()
        hidden = [p for p in s.panes() if p["hidden"]]
        check("there is something to expand", hidden != [], str(s.panes()))
        if not hidden:
            return

        target = hidden[0]["id"]
        s.api("focus", id=target)
        s.settle()
        now = {p["id"]: p for p in s.panes()}
        check(
            "focusing a collapsed pane expands it",
            not now[target]["hidden"],
            str(now[target]),
        )
        check(
            "and something else collapsed in its place",
            any(p["hidden"] for p in s.panes()),
        )
        invariants(s, "after expanding")


def test_click_a_header():
    with Session(SH, cols=120, rows=32) as s:
        grid(s)
        s.resize(56, 16)
        s.settle()
        hidden = [p for p in s.panes() if p["hidden"]]
        if not hidden:
            check("there is a header to click", False)
            return
        h = hidden[0]
        snap = s.snapshot()
        action = snap.hit_at(h["x"] + 1, h["y"])
        check(
            "a collapsed header is clickable", action == f"focus:{h['id']}", str(action)
        )
        s.click(h["x"] + 1, h["y"])
        s.settle()
        check(
            "clicking a header expands that pane",
            not [p for p in s.panes() if p["id"] == h["id"]][0]["hidden"],
        )


def test_hidden_panes_keep_running():
    with Session(SH, cols=120, rows=32) as s:
        grid(s)
        first = s.panes()[0]
        s.api("focus", id=first["id"])
        s.settle()
        s.raw("written-before-collapse")
        s.settle()
        size_before = (first["content_w"], first["content_h"])

        # collapse it by focusing another pane and squeezing
        s.api("focus", id=s.panes()[-1]["id"])
        s.resize(50, 14)
        s.settle()
        now = {p["id"]: p for p in s.panes()}
        check(
            "the pane we wrote to is hidden",
            now[first["id"]]["hidden"],
            str(now[first["id"]]),
        )
        check(
            "a hidden pane is not resized, so its program does not reflow",
            (now[first["id"]]["content_w"], now[first["id"]]["content_h"])
            == size_before,
            f"{size_before} -> {(now[first['id']]['content_w'], now[first['id']]['content_h'])}",
        )

        s.resize(120, 32)
        s.api("focus", id=first["id"])
        s.settle()
        check(
            "its content survived being collapsed",
            "written-before-collapse" in s.snapshot().screen(),
            repr(s.snapshot().screen()[:200]),
        )


def test_tiny_terminal():
    """Nothing may crash or draw off-screen, however absurd the size."""
    with Session(SH, cols=120, rows=32) as s:
        grid(s)
        for cols, rows in [(40, 10), (24, 6), (12, 4), (80, 5), (200, 60), (60, 16)]:
            s.resize(cols, rows)
            s.settle(60)
            invariants(s, f"{cols}x{rows}")
        check("still alive after every size", s.api("alive")["alive"])


# ---- collapsing flattens the hierarchy -------------------------------------


def nested_layout():
    """left column of three stacked panes, plus one on the right."""
    f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
    f.write(
        'layout {\n tab name="t" {\n  pane split="rows" { pane\n'
        "   pane\n   pane }\n  pane\n }\n}\n"
    )
    f.close()
    return f.name


def test_collapsing_flattens_the_tree():
    lay = nested_layout()
    with Session(SH, cols=50, rows=26, layout=lay) as s:
        s.settle(20)
        ids = [p["id"] for p in s.panes()]
        s.api("focus", id=ids[-1])
        s.settle(20)
        panes = s.panes()
        hidden = [p for p in panes if p["hidden"]]
        shown = [p for p in panes if not p["hidden"]]

        check(
            "everything but the focused pane becomes a header",
            len(hidden) == 3 and len(shown) == 1,
            str(panes),
        )
        check(
            "each header is exactly one row",
            all(p["h"] == 1 for p in hidden),
            str([p["h"] for p in hidden]),
        )
        # The nesting is what there is no room to express, so it stops
        # existing: no two panes sit side by side while collapsed.
        check(
            "no two panes share a row",
            len({p["y"] for p in hidden}) == len(hidden),
            str([p["y"] for p in hidden]),
        )
        check(
            "the headers come first and the body below them",
            shown[0]["y"] > max(p["y"] for p in hidden),
            str(panes),
        )
    os.unlink(lay)


def test_every_collapsed_pane_is_clickable():
    """The bug flattening fixes: a collapsed subtree used to give every pane
    inside it the same rect and one hit for the first of them."""
    lay = nested_layout()
    with Session(SH, cols=50, rows=26, layout=lay) as s:
        s.settle(20)
        ids = [p["id"] for p in s.panes()]
        s.api("focus", id=ids[-1])
        s.settle(20)
        snap = s.snapshot()
        hidden = [p for p in s.panes() if p["hidden"]]

        actions = [snap.hit_at(p["x"] + 2, p["y"]) for p in hidden]
        check(
            "every header carries its own pane's focus action",
            actions == [f"focus:{p['id']}" for p in hidden],
            str(actions),
        )
        check(
            "and they are all different",
            len(set(actions)) == len(actions),
            str(actions),
        )

        # Clicking the *last* header must reach that pane, not the first one.
        target = hidden[-1]
        s.click(target["x"] + 2, target["y"])
        s.settle(20)
        now = {p["id"]: p for p in s.panes()}
        check(
            "clicking the last header expands that exact pane",
            not now[target["id"]]["hidden"] and now[target["id"]]["h"] > 1,
            str(s.panes()),
        )
    os.unlink(lay)


def test_a_flattened_stack_offers_no_resize_handles():
    """Nothing is side by side any more, so there is nothing to drag."""
    lay = nested_layout()
    with Session(SH, cols=50, rows=26, layout=lay) as s:
        s.settle(20)
        ids = [p["id"] for p in s.panes()]
        s.api("focus", id=ids[-1])
        s.settle(20)
        snap = s.snapshot()
        edges = [h for h in snap.hits if h["action"].startswith("edge:")]
        check("a collapsed stack registers no resize edges", edges == [], str(edges))
    os.unlink(lay)


def test_a_tab_is_laid_out_or_it_is_a_list_never_both():
    """The whole tab flattens, not just the subtree that could not fit.

    Half a screen of panes next to half a screen of headers explains neither
    what happened nor what to do about it, so it is not a state we produce.
    """
    lay = nested_layout()
    # min_pane tuned so the *right column* alone cannot fit while the left
    # pane, on its own, comfortably could. Locally, only the right would
    # collapse; globally, everything does.
    conf = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
    conf.write("min_pane cols=24 rows=17\n")
    conf.close()
    with Session(SH, cols=116, rows=52, config=conf.name, layout=lay) as s:
        s.settle(20)
        panes = s.panes()
        shown = [p for p in panes if not p["hidden"]]
        hidden = [p for p in panes if p["hidden"]]

        check("exactly one pane is open", len(shown) == 1, str(panes))
        check(
            "every other pane is a header, including the one that did fit",
            len(hidden) == len(panes) - 1,
            str(panes),
        )
        check(
            "the headers all span the full width",
            len({p["w"] for p in hidden}) == 1 and hidden[0]["w"] == shown[0]["w"],
            str([(p["id"], p["w"]) for p in panes]),
        )
        # The give-away for the old behaviour: a full-height pane beside a
        # stack. Nothing may sit next to a header any more.
        check(
            "no pane sits beside a header",
            all(p["x"] == hidden[0]["x"] for p in panes),
            str([(p["id"], p["x"]) for p in panes]),
        )
    os.unlink(conf.name)
    os.unlink(lay)


# ---- hovering a row in the stack -------------------------------------------

FRAME_FOCUS = "#7aa2f7"  # the rule, while hovered
TITLE_FOCUS = "#ffffff"  # the title, while hovered
FRAME_IDLE = "#45454a"


def stacked_session():
    """A tab small enough to be a list, with two headers and one body."""
    lay = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
    lay.write(
        'layout {\n tab name="t" {\n  pane\n'
        '  pane split="rows" { pane\n   pane }\n }\n}\n'
    )
    lay.close()
    conf = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
    conf.write("min_pane cols=24 rows=17\n")
    conf.close()
    return Session(SH, cols=100, rows=20, config=conf.name, layout=lay.name)


def hover(s, x, y):
    s.send(rf"\e[<35;{x + 1};{y + 1}M")


def rule_fg(snap, pane):
    """Colour of the header's rule, away from the title."""
    return (snap.style_at(pane["x"] + pane["w"] - 3, pane["y"]) or {}).get("fg")


def row_bg(snap, pane):
    return (snap.style_at(pane["x"] + pane["w"] - 3, pane["y"]) or {}).get("bg")


def test_hovering_a_row_lights_it_up():
    with stacked_session() as s:
        s.settle(20)
        headers = [p for p in s.panes() if p["hidden"]]
        check("the tab is a list with rows to hover", len(headers) >= 2, str(s.panes()))
        if len(headers) < 2:
            return

        snap = s.snapshot()
        check(
            "nothing is lit before the pointer arrives",
            all(rule_fg(snap, p) == FRAME_IDLE for p in headers),
            str([rule_fg(snap, p) for p in headers]),
        )

        hover(s, headers[0]["x"] + 4, headers[0]["y"])
        snap = s.snapshot()
        check(
            "the row under the pointer is lit",
            rule_fg(snap, headers[0]) == FRAME_FOCUS,
            str(rule_fg(snap, headers[0])),
        )
        check(
            "and only that row",
            all(rule_fg(snap, p) == FRAME_IDLE for p in headers[1:]),
            "",
        )
        check(
            "by its foreground: no bar is painted behind it",
            row_bg(snap, headers[0]) is None,
            str(row_bg(snap, headers[0])),
        )

        hover(s, headers[1]["x"] + 4, headers[1]["y"])
        snap = s.snapshot()
        check(
            "the light follows the pointer down the list",
            rule_fg(snap, headers[1]) == FRAME_FOCUS
            and rule_fg(snap, headers[0]) == FRAME_IDLE,
            "",
        )


def test_leaving_the_stack_puts_the_light_out():
    with stacked_session() as s:
        s.settle(20)
        headers = [p for p in s.panes() if p["hidden"]]
        body = [p for p in s.panes() if not p["hidden"]][0]
        if not headers:
            return
        hover(s, headers[0]["x"] + 4, headers[0]["y"])
        hover(s, body["content_x"] + 2, body["content_y"] + 2)
        snap = s.snapshot()
        check(
            "no row is lit once the pointer is off the list",
            all(rule_fg(snap, p) == FRAME_IDLE for p in headers),
            "",
        )


def test_hovering_a_row_does_not_open_it():
    """Feedback about where the pointer is, not an action. Opening on hover
    would make the list shuffle under the mouse as you read it."""
    with stacked_session() as s:
        s.settle(20)
        before = [p["id"] for p in s.panes() if p["focused"]]
        headers = [p for p in s.panes() if p["hidden"]]
        if not headers:
            return
        for h in headers:
            hover(s, h["x"] + 4, h["y"])
        s.settle(20)
        after = [p["id"] for p in s.panes() if p["focused"]]
        check(
            "hovering every row changes nothing about which is open",
            before == after,
            f"{before} -> {after}",
        )
        check(
            "and they are all still headers",
            len([p for p in s.panes() if p["hidden"]]) == len(headers),
            "",
        )

        # ...but clicking one still does open it.
        s.click(headers[-1]["x"] + 4, headers[-1]["y"])
        s.settle(20)
        now = {p["id"]: p for p in s.panes()}
        check(
            "clicking the row it lit is what opens that pane",
            not now[headers[-1]["id"]]["hidden"],
            str(s.panes()),
        )


def test_a_header_is_shaped_like_a_pane_top():
    """The row is not a label above a pane; it is a pane, closed."""
    with stacked_session() as s:
        s.settle(20)
        snap = s.snapshot()
        headers = [p for p in s.panes() if p["hidden"]]
        if not headers:
            return
        for h in headers:
            row = snap.line(h["y"])[h["x"] : h["x"] + h["w"]]
            check(
                f"row at y={h['y']} opens and closes with a corner",
                row.startswith("\u256d") and row.endswith("\u256e"),
                repr(row),
            )
            check(
                f"row at y={h['y']} keeps air around its title",
                " pane " in row or " p " in row,
                repr(row),
            )
            # The title must not be welded to the rule on either side.
            i = row.find("pane")
            check(
                f"row at y={h['y']} has a rule, a space, then the title",
                i > 2 and row[i - 1] == " " and row[i - 2] == "\u2500",
                repr(row),
            )


def test_the_title_inset_is_configurable():
    lay = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
    lay.write(
        'layout {\n tab name="t" {\n  pane\n'
        '  pane split="rows" { pane\n   pane }\n }\n}\n'
    )
    lay.close()
    seen = {}
    for inset in (0, 6):
        conf = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
        conf.write(f"min_pane cols=24 rows=17\ntitle_inset {inset}\n")
        conf.close()
        with Session(SH, cols=100, rows=20, config=conf.name, layout=lay.name) as s:
            s.settle(20)
            h = [p for p in s.panes() if p["hidden"]][0]
            row = s.snapshot().line(h["y"])[h["x"] : h["x"] + h["w"]]
            seen[inset] = row.find("pane")
        os.unlink(conf.name)
    check(
        "a bigger inset pushes the title further from the corner",
        seen[6] == seen[0] + 6,
        str(seen),
    )
    os.unlink(lay.name)


def test_width_collapses_a_stacked_layout_too():
    """The floor is per pane and both-ways, not per split.

    A split node only ever asks about the dimension it divides, so a column of
    stacked panes asked about height and never about width -- and could be
    squeezed to a few cells wide with nothing in the tree responsible for
    noticing. Whatever arrangement produced a pane, it has to clear the floor
    on both of its own sides.
    """
    with Session(SH, cols=100, rows=24) as s:
        s.settle(20)
        s.api("split", dir="rows")  # stacked: width is the perpendicular axis
        s.settle(20)
        check(
            "two stacked panes, both open, at a comfortable width",
            len([p for p in s.panes() if not p["hidden"]]) == 2,
            str(s.panes()),
        )

        s.resize(60, 24)
        s.settle(20)
        check(
            "still open while each pane clears the floor",
            not any(p["hidden"] for p in s.panes()),
            str(s.panes()),
        )

        s.resize(26, 24)
        s.settle(20)
        check(
            "narrow enough, and a stacked layout collapses like any other",
            any(p["hidden"] for p in s.panes()),
            str(s.panes()),
        )

        s.resize(100, 24)
        s.settle(20)
        check(
            "and comes back when there is room again",
            not any(p["hidden"] for p in s.panes()),
            str(s.panes()),
        )


def test_height_collapses_a_side_by_side_layout_too():
    """The same hole, the other way round."""
    with Session(SH, cols=100, rows=24) as s:
        s.settle(20)
        s.api("split", dir="cols")  # side by side: height is perpendicular
        s.settle(20)
        check(
            "two panes side by side at a comfortable height",
            not any(p["hidden"] for p in s.panes()),
            str(s.panes()),
        )

        s.resize(100, 9)
        s.settle(20)
        check(
            "short enough, and a side-by-side layout collapses",
            any(p["hidden"] for p in s.panes()),
            str(s.panes()),
        )


if __name__ == "__main__":
    test_collapse()
    test_collapse_expand_cycle()
    test_focus_expands()
    test_click_a_header()
    test_hidden_panes_keep_running()
    test_tiny_terminal()
    test_collapsing_flattens_the_tree()
    test_every_collapsed_pane_is_clickable()
    test_a_flattened_stack_offers_no_resize_handles()
    test_a_tab_is_laid_out_or_it_is_a_list_never_both()
    test_hovering_a_row_lights_it_up()
    test_leaving_the_stack_puts_the_light_out()
    test_hovering_a_row_does_not_open_it()
    test_a_header_is_shaped_like_a_pane_top()
    test_the_title_inset_is_configurable()
    test_width_collapses_a_stacked_layout_too()
    test_height_collapses_a_side_by_side_layout_too()
    sys.exit(report())