aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-07 15:52:00 +0200
committeruser <user@clank>2026-09-07 15:52:00 +0200
commite9147fba11b735259a90089dd7207853ff49a3ad (patch)
tree6dc86b56987c7bb2e2dba4a3151cf4576efab2b0 /tests
parentscreen: commit a wide cell's tail to prev, or the diff leaves a hole (diff)
parentapp: cut the modals, toasts and splash out of the images under them (diff)
downloadslosh-e9147fba11b735259a90089dd7207853ff49a3ad.tar.gz
slosh-e9147fba11b735259a90089dd7207853ff49a3ad.tar.xz
slosh-e9147fba11b735259a90089dd7207853ff49a3ad.zip
Merge branch 'graphics-fixes' (PR #2 from iksteen): five fixes to the kitty passthrough, found through a mirror
One synchronized message per frame with cells and images together, a placement said once rather than every frame, z=0 stated so a mirror knows where the picture goes, a float cutting an image into pieces instead of erasing it, and overlays cut out of the images under them. Found running slosh inside shellglass, real on a terminal too.
Diffstat (limited to 'tests')
-rw-r--r--tests/live_graphics.py67
-rw-r--r--tests/test_graphics.py110
2 files changed, 169 insertions, 8 deletions
diff --git a/tests/live_graphics.py b/tests/live_graphics.py
index 74e4b2c..934cd46 100644
--- a/tests/live_graphics.py
+++ b/tests/live_graphics.py
@@ -58,12 +58,30 @@ time.sleep(30)
"""
-def spawn(cols=80, rows=24):
+# A small image that stays put while the screen around it changes: the counter
+# repaints one line in place (\r, no newline), so nothing scrolls and the
+# picture neither moves nor changes. Every frame it provokes is a frame that
+# must NOT say the placement again.
+STILL = r"""
+import base64, sys, time
+px = base64.b64encode(bytes([255, 0, 0] * 8)).decode() # 4x2 RGB
+sys.stdout.write("\x1b_Ga=T,f=24,q=2,s=4,v=2,i=7,c=6,r=2;%s\x1b\\\r\n" % px)
+sys.stdout.flush()
+for i in range(120):
+ sys.stdout.write("tick %d \r" % i)
+ sys.stdout.flush()
+ time.sleep(0.02)
+time.sleep(30)
+"""
+
+
+def spawn(cols=80, rows=24, child=None, session=None):
pid, fd = pty.fork()
if pid == 0:
os.environ["TERM"] = "xterm-ghostty"
os.environ["SLOSH_CONFIG"] = "/nonexistent/slosh.kdl"
- os.execv(BIN, [BIN, "-s", SESSION, "--", "python3", "-c", CHILD])
+ os.execv(BIN, [BIN, "-s", session or SESSION, "--", "python3", "-c",
+ child or CHILD])
os._exit(127)
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
return pid, fd
@@ -87,6 +105,28 @@ def drain(fd, idle=0.4, limit=8.0):
return buf
+def unsynced(buf):
+ """kitty commands that reach the client outside a synchronized update.
+
+ A frame is a cell diff and then the placements that sit on top of it. A
+ terminal that presents the diff before the placements arrive draws the
+ cells under a picture with no picture on them, so every image blinks once
+ per frame -- which is what happens the moment anything re-chunks the
+ stream on the way out (a mirror reading the pty a few KB at a time). The
+ markers are what stop the halves being presented separately, so every
+ image command has to be inside a pair.
+ """
+ depth, out = 0, []
+ for m in re.finditer(rb"\x1b\[\?2026h|\x1b\[\?2026l|\x1b_G([^\x1b]*)\x1b", buf):
+ if m.group() == b"\x1b[?2026h":
+ depth += 1
+ elif m.group() == b"\x1b[?2026l":
+ depth -= 1
+ elif depth <= 0:
+ out.append(m.group(1)[:40])
+ return out
+
+
def check(name, cond, detail=""):
print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}")
return cond
@@ -130,6 +170,13 @@ def main():
placed = any(ctl.startswith(b"a=p") for ctl, _ in cmds)
ok &= check("and the image is placed while visible", placed, repr(buf[-300:]))
+ loose = unsynced(buf)
+ ok &= check(
+ "every image command travels inside a synchronized update",
+ not loose,
+ f"{len(loose)} outside: {loose[:4]}",
+ )
+
# The child's flood scrolled it away while nothing was being read: the
# deletion rode a frame the full outbox may well have dropped, and it must
# be said again until it lands -- or the client draws the placement at a
@@ -172,6 +219,22 @@ def main():
os.waitpid(pid, os.WNOHANG) == (0, 0),
)
+ # A placement is a standing instruction, not a per-frame one: a picture
+ # that has not moved must not be re-sent while the screen around it
+ # repaints. Re-sending it is invisible on a terminal that keeps the pixels
+ # and ruinous on anything mirroring the stream, which re-decodes the image
+ # and rebuilds its view every time it hears the placement again.
+ pid3, fd3 = spawn(child=STILL, session=SESSION + "-still")
+ still = drain(fd3, idle=0.6, limit=20.0)
+ places = [ctl for ctl, _ in gfx_commands(still) if ctl.startswith(b"a=p")]
+ frames = still.count(b"\x1b[?2026h")
+ ok &= check(
+ "a still picture is placed once, however much repaints around it",
+ len(places) == 1 and frames > 10,
+ f"{len(places)} placements across {frames} frames",
+ )
+ os.kill(pid3, 9)
+
os.write(fd2, b"\x01q")
drain(fd2, idle=0.2, limit=2.0)
for child in (pid, pid2):
diff --git a/tests/test_graphics.py b/tests/test_graphics.py
index f8d7a0d..7430224 100644
--- a/tests/test_graphics.py
+++ b/tests/test_graphics.py
@@ -289,6 +289,26 @@ def test_sub_cell_offsets_survive_to_the_client():
)
+def test_a_placement_says_it_sits_above_the_text():
+ """z=0 is kitty's default, so saying it changes nothing on a terminal.
+
+ It is the whole picture for anything mirroring the stream, which cannot
+ otherwise tell "the emitter wants this over the text" from "the emitter
+ said nothing" -- and one that guesses "under" loses the image behind the
+ first background painted over those cells, dim_unfocused being the one
+ that paints over every cell of a pane at once.
+ """
+ with Session(sends_image(), cols=44, rows=10) as s:
+ s.settle(200)
+ raw = s.api("graphics", format="bytes")["bytes"]
+ place = [c for c in raw.split("\x1b") if c.startswith("_Ga=p")]
+ check(
+ "the placement states its z",
+ place and "z=0" in place[0],
+ str(place),
+ )
+
+
def test_a_placement_with_no_offset_emits_none():
with Session(transmits_then_places("p,i=7,p=1,q=2,c=6,r=2"), cols=44, rows=10) as s:
s.settle(200)
@@ -592,10 +612,12 @@ def test_scrolled_away_placements_are_dropped():
def test_a_float_occludes_a_placement():
"""The cell compositor gets occlusion free from paint order; placements
- are sent after the diff and get it from clipping (D22): a clean edge
- crops -- the pane-edge arithmetic aimed at another clipper -- and a
- float in the middle is a shape one placement cannot express, so that
- placement is suppressed for the frame and returns when the float moves.
+ are sent after the diff and get it from clipping (D22): what is left of
+ the image once the float is taken out of it. A clean edge leaves one
+ rectangle, and anything else leaves several -- each placed in its own
+ right, because a placement is one rectangle and the remainder need not
+ be. Suppressing the image instead, which is what this used to do, meant
+ a floating pane crossing a picture erased all of it.
Every pane here runs the same command, so the float shows the image too;
the tiled pane's placement is told apart by the image id it had before
@@ -622,10 +644,22 @@ def test_a_float_occludes_a_placement():
str(got),
)
- # Across the middle: no single rect can say what remains.
+ # Across the middle: what is left is the columns on either side of it.
s.api("float", x=img["x"] + 10, y=0, w=30, h=28)
s.settle(20)
- check("a middle strip suppresses it", mine() == [], str(places(s)))
+ got = sorted(mine(), key=lambda p: p["x"])
+ check(
+ "a middle strip leaves the columns beside it",
+ len(got) == 2
+ and (got[0]["x"], got[0]["cols"]) == (img["x"], 10)
+ and (got[1]["x"], got[1]["cols"]) == (img["x"] + 40, img["cols"] - 40),
+ str(got),
+ )
+ check(
+ "and each piece crops to the cells it covers",
+ all(p["cols"] and p["rows"] == img["rows"] for p in got),
+ str(got),
+ )
# Moved clear: the placement returns whole.
s.api("float", x=img["x"], y=14, w=30, h=12)
@@ -637,6 +671,25 @@ def test_a_float_occludes_a_placement():
str(got),
)
+ # Landing inside it: what is left is the strips around the float, and
+ # they tile exactly the image minus what the float covers -- the
+ # float's rect being its frame's, not the content's.
+ s.api("float", x=img["x"] + 10, y=img["y"] + 1, w=20, h=3)
+ s.settle(20)
+ got = mine()
+ fl = [p for p in s.api("panes")["panes"] if p["floating"]][0]
+ ox0, oy0 = max(img["x"], fl["x"]), max(img["y"], fl["y"])
+ ox1 = min(img["x"] + img["cols"], fl["x"] + fl["w"])
+ oy1 = min(img["y"] + img["rows"], fl["y"] + fl["h"])
+ hidden = max(0, ox1 - ox0) * max(0, oy1 - oy0)
+ covered = sum(p["cols"] * p["rows"] for p in got)
+ check(
+ "a float over the middle of an image leaves the strips around it",
+ len(got) > 1 and covered == img["cols"] * img["rows"] - hidden,
+ f"{len(got)} pieces covering {covered} of "
+ f"{img['cols'] * img['rows'] - hidden}: {got}",
+ )
+
# Over the top rows: cropped from the top, the source origin moving.
s.api("float", x=0, y=0, w=100, h=img["y"] + 3)
s.settle(20)
@@ -648,6 +701,49 @@ def test_a_float_occludes_a_placement():
)
+def test_a_modal_is_not_drawn_under_an_image():
+ """The cheatsheet, a toast and the splash are painted over the panes, and
+ a placement is not painted at all -- the client's terminal draws the image
+ over the cells whatever order they arrived in. So an overlay has to be cut
+ out of the image underneath it, the way a float is, or it reads through a
+ picture that is drawn on top of it."""
+ with Session(sends_image(cols=60, rows=16, after="cat"), cols=100, rows=28) as s:
+ s.settle(200)
+ base = places(s)
+ check("placed to begin with", len(base) == 1, str(base))
+ if not base:
+ return
+ img = base[0]
+ mid = (img["x"] + img["cols"] // 2, img["y"] + img["rows"] // 2)
+ covers = lambda x, y: any(
+ p["x"] <= x < p["x"] + p["cols"] and p["y"] <= y < p["y"] + p["rows"]
+ for p in places(s)
+ )
+ check("the image covers the middle of itself", covers(*mid), str(base))
+
+ s.send(r"\x01?") # the cheatsheet, centred over everything
+ s.settle()
+ check(
+ "the cheatsheet is cut out of the image",
+ not covers(*mid) and places(s) != [],
+ str(places(s)),
+ )
+ check(
+ "and what is beside it is still placed",
+ covers(img["x"], img["y"]),
+ str(places(s)),
+ )
+
+ s.send("q") # anything dismisses it
+ s.settle()
+ got = places(s)
+ check(
+ "the image is whole again once it is dismissed",
+ len(got) == 1 and got[0]["cols"] == img["cols"],
+ str(got),
+ )
+
+
if __name__ == "__main__":
test_a_pane_image_reaches_the_screen()
test_a_png_is_decoded_and_placed()
@@ -661,6 +757,7 @@ if __name__ == "__main__":
test_an_image_outlives_a_screen_clear()
test_a_screen_clear_still_removes_what_is_on_screen()
test_sub_cell_offsets_survive_to_the_client()
+ test_a_placement_says_it_sits_above_the_text()
test_a_placement_with_no_offset_emits_none()
test_a_natural_image_is_never_rescaled_as_it_moves()
test_a_scaled_image_keeps_the_cell_count_it_asked_for()
@@ -675,4 +772,5 @@ if __name__ == "__main__":
test_scrolled_away_placements_are_dropped()
test_an_undelivered_frame_stays_owed()
test_a_float_occludes_a_placement()
+ test_a_modal_is_not_drawn_under_an_image()
sys.exit(report())