aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_screen.py
blob: 52dfc960142f4cfd84dcff0fbcf0af8ad5d7d74e (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
#!/usr/bin/env python3
"""Composition, styling, cursor, scrollback, resize, and the input round trip.

Everything here runs through the headless driver, so it is deterministic and
takes milliseconds. Content is addressed through the layout (`content_x/y`)
rather than assumed to start at 0,0 — panes have chrome, and a test that
hardcodes the offset is a test that breaks every time the chrome changes.
"""

import sys

from harness import Session, check, report

CAT = ["/bin/sh", "-c", "stty raw -echo; cat -v"]


def SH(script):
    return ["/bin/sh", "-c", script]


def test_text_and_cursor():
    with Session(SH("stty raw -echo; cat"), cols=30, rows=8) as s:
        s.settle()
        s.raw("hello")
        s.settle()
        p = s.pane()
        snap = s.snapshot()
        check(
            "text lands on the pane's first row",
            snap.pane_line(p, 0).startswith("hello"),
            repr(snap.pane_line(p, 0)),
        )
        check(
            "cursor follows the text",
            (snap.cursor["x"], snap.cursor["y"])
            == (p["content_x"] + 5, p["content_y"]),
            str(snap.cursor),
        )

        s.raw("\\r\\nsecond")
        s.settle()
        snap = s.snapshot()
        check(
            "newline moves to the next row",
            snap.pane_line(p, 1).startswith("second"),
            repr(snap.pane_line(p, 1)),
        )
        check(
            "cursor is on that row",
            snap.cursor["y"] == p["content_y"] + 1,
            str(snap.cursor),
        )


def test_styles():
    with Session(
        SH(r'printf "n\033[1mB\033[31mR\033[0mn"; sleep 5'), cols=30, rows=6
    ) as s:
        s.settle()
        p, snap = s.pane(), s.snapshot()
        cx, cy = p["content_x"], p["content_y"]
        check(
            "plain cell has no style run",
            snap.style_at(cx, cy) is None,
            str(snap.style_at(cx, cy)),
        )
        b = snap.style_at(cx + 1, cy)
        check("bold is recorded", b and b["attrs"] == ["bold"], str(b))
        r = snap.style_at(cx + 2, cy)
        check(
            "colour is resolved to rgb",
            r and r["fg"] is not None and "bold" in r["attrs"],
            str(r),
        )
        check(
            "reset ends the run",
            snap.style_at(cx + 3, cy) is None,
            str(snap.style_at(cx + 3, cy)),
        )


def test_wide_and_clusters():
    with Session(SH('printf "[\\346\\227\\245]"; sleep 5'), cols=30, rows=6) as s:
        s.settle()
        p, snap = s.pane(), s.snapshot()
        check(
            "wide char occupies one cell in the dump",
            snap.pane_line(p, 0).startswith("[日]"),
            repr(snap.pane_line(p, 0)),
        )

    with Session(
        SH('printf "[\\360\\237\\207\\263\\360\\237\\207\\261]"; sleep 5'),
        cols=30,
        rows=6,
    ) as s:
        s.settle()
        p, snap = s.pane(), s.snapshot()
        check(
            "a flag is one grapheme cluster, not two",
            snap.pane_line(p, 0).startswith("[🇳🇱]"),
            repr(snap.pane_line(p, 0)),
        )


def test_scrollback():
    with Session(SH("stty raw -echo; cat"), cols=30, rows=7) as s:
        s.settle()
        p = s.pane()
        n = p["content_h"]
        s.raw("\\r\\n".join(str(i) for i in range(1, n + 3)))
        s.settle()
        snap = s.snapshot()
        check(
            "viewport shows the tail, not the head",
            snap.pane_line(p, 0).startswith("3")
            and snap.pane_line(p, n - 1).startswith(str(n + 2)),
            repr(snap.pane_text(p)),
        )


def test_resize():
    with Session(SH("stty raw -echo; cat"), cols=30, rows=8) as s:
        s.settle()
        s.resize(50, 12)
        s.settle()
        snap, p = s.snapshot(), s.pane()
        check(
            "resize changes the composited size",
            snap.cols == 50 and snap.rows == 12 and len(snap.line(0)) == 50,
            f"{snap.cols}x{snap.rows}",
        )
        # chrome budget: gap 1 (x2 aspect => 2 cols) each side, a border, the
        # one-row tab strip along the top and the status line along the bottom
        check(
            "the pane grows with the screen, minus its chrome",
            p["content_w"] == snap.cols - 6 and p["content_h"] == snap.rows - 6,
            str(p),
        )
        check("pane still responds after resize", s.alive())


def test_input_round_trip():
    """The decoder and the per-pane encoder, without a pty in sight."""
    with Session(CAT, cols=50, rows=8) as s:
        s.settle()
        s.send(r"\e[1;5A")  # legacy ctrl-up
        s.settle()
        check("legacy key reaches the pane", "^[[1;5A" in s.snapshot().screen())

    with Session(CAT, cols=50, rows=8) as s:
        s.settle()
        s.send(r"\e[98;5u")  # kitty ctrl-b -> legacy pane
        s.settle()
        check(
            "kitty key is re-encoded for a legacy pane", "^B" in s.snapshot().screen()
        )

    with Session(CAT, cols=50, rows=8) as s:
        s.settle()
        s.send(r"\e[200~pasted\e[201~")
        s.settle()
        check(
            "paste is unwrapped for a pane that did not ask",
            "pasted" in s.snapshot().screen(),
        )

    kitty = [
        "/bin/sh",
        "-c",
        'stty raw -echo; printf "\\033[>1u\\033[?1000h\\033[?1006h"; cat -v',
    ]
    with Session(kitty, cols=50, rows=8) as s:
        s.settle()
        s.send(r"\x02")  # legacy ctrl-b -> kitty pane
        s.settle()
        check(
            "legacy key is re-encoded for a kitty pane",
            "^[[98;5u" in s.snapshot().screen(),
            s.snapshot().screen(),
        )

    # Shift consumed by the character stays consumed. The decoder reads the
    # shift out of `:` (shift+; on the layout the legacy protocol assumes),
    # and the encoder must not let that inferred shift force a CSI u escape:
    # nvim receives \e[59;2u as shift+semicolon and the command line never
    # opens. Text the shift produced goes through as text; a chord whose
    # shift is real (shift+up, ctrl+shift+a) keeps its escape.
    kitty_dis = [
        "/bin/sh",
        "-c",
        'stty raw -echo; printf "\\033[>1u"; cat -v',
    ]
    with Session(kitty_dis, cols=50, rows=8) as s:
        s.settle()
        s.send(":A")  # legacy shifted punctuation + capital
        s.send(r"\e[59:58;2u")  # the same colon from a kitty outer
        s.send(r"\e[1;2A")  # shift+up: not text, keeps the modifier
        s.settle()
        screen = s.snapshot().screen()
        check(
            "shifted text reaches a kitty pane as text",
            ":A:" in screen and "59" not in screen,
            screen,
        )
        check("real shift chords keep their escape", "^[[1;2A" in screen, screen)


def test_terminal_replies():
    """WRITE_PTY: a query from the app must be answered on its own stdin.

    Regression: the callback options are pointer-typed, so passing &fn stored a
    stack address that jumped into a dead frame the moment pane_new returned.
    Nothing exercised it until a program asked the terminal a question.
    """
    with Session(SH('stty raw -echo; printf "\\033[6n"; cat -v'), cols=40, rows=8) as s:
        s.settle()
        out = s.snapshot().pane_text(s.pane())
        check(
            "device status report is answered",
            "R" in out and "^[[" in out,
            repr(out[:60]),
        )

    with Session(SH('printf "\\033]2;build.sh\\007"; sleep 5'), cols=44, rows=8) as s:
        s.settle()
        p = s.pane()
        check("OSC 2 sets the pane title", p["title"] == "build.sh", str(p["title"]))
        check(
            "the title is drawn in the frame",
            "build.sh" in s.snapshot().line(p["y"]),
            repr(s.snapshot().line(p["y"])),
        )


def test_hitlist_contract():
    with Session(SH("sleep 5"), cols=30, rows=6) as s:
        s.settle()
        p, snap = s.pane(), s.snapshot()
        check(
            "the pane body is hit-testable",
            snap.hit_at(p["content_x"], p["content_y"]) == f"pane:{p['id']}",
            str(snap.hit_at(p["content_x"], p["content_y"])),
        )
        check(
            "the gap ring belongs to nothing",
            snap.hit_at(0, 0) is None,
            str(snap.hit_at(0, 0)),
        )


if __name__ == "__main__":
    test_text_and_cursor()
    test_styles()
    test_wide_and_clusters()
    test_scrollback()
    test_resize()
    test_input_round_trip()
    test_terminal_replies()
    test_hitlist_contract()
    sys.exit(report())