aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_bell.py
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 19:20:25 +0200
committeruser <user@clank>2026-08-14 19:20:25 +0200
commit0de9bbc8ea84f8ad766abbf602e603fd90bd2a66 (patch)
treee56992e66052a2c196709e6aa755fa3d092f5828 /tests/test_bell.py
parentsl0ppty: one theme name per decision, and a tab you can see (diff)
downloadslosh-0de9bbc8ea84f8ad766abbf602e603fd90bd2a66.tar.gz
slosh-0de9bbc8ea84f8ad766abbf602e603fd90bd2a66.tar.xz
slosh-0de9bbc8ea84f8ad766abbf602e603fd90bd2a66.zip
sl0ppty: a pane that rings says so
libghostty-vt already had a BEL effect; nothing was listening. A pane now marks its titlebar when one arrives, and stays marked until the pane is looked at. It marks the *tab* as well, which is the half that makes it useful. A bell exists for the pane you are not watching, and a mark that only appeared on screen would be missing precisely when the pane is in a background tab — which is where an agent finishing a job, or a build wanting input, actually happens. Looking at a pane is the acknowledgement, and it is done once per frame before anything is drawn rather than while the pane itself is painted. The tab strip is drawn first, so clearing during the pane's own paint left the strip reporting a bell that had already been answered for one frame — visible as a stale dot that went away on the next unrelated repaint. Doing it up front also means every route to "this pane is focused" clears it, not only the routes that thought to. The mark is a configurable string rather than a fixed glyph. The obvious choice is an emoji bell, emoji are drawn two columns wide by many terminals, and screen_text books every chrome cell as one — so the default is a narrow bullet and anyone who knows their own terminal can say otherwise. config.kdl has no \\u escape, so it holds the character itself; that cost a round trip to notice. bell_indicator false makes a bell silent and invisible, which is a real choice and not an oversight, so it is spelled out in the config.
Diffstat (limited to 'tests/test_bell.py')
-rw-r--r--tests/test_bell.py188
1 files changed, 188 insertions, 0 deletions
diff --git a/tests/test_bell.py b/tests/test_bell.py
new file mode 100644
index 0000000..27307fc
--- /dev/null
+++ b/tests/test_bell.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""A pane that rang, and the several places that have to say so.
+
+A bell exists for the pane you are *not* looking at, so most of what matters is
+where it shows up when the pane is off-screen, and that looking at it is what
+puts it out.
+"""
+import os
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat']
+MARK = "\u2022"
+
+
+def cfg(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+def lay(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+TWO_PANES = 'layout {\n tab name="api" {\n pane\n pane\n }\n}\n'
+TWO_TABS = ('layout {\n tab name="api" {\n pane\n pane\n }\n'
+ ' tab name="notes" {\n pane\n }\n}\n')
+
+
+def ring(s, pane_id, back_to):
+ """Ring the bell in a pane while looking at another one."""
+ s.api("focus", id=pane_id)
+ s.settle(10)
+ s.raw(r"\x07")
+ s.settle(30)
+ s.api("focus", id=back_to)
+ s.settle(30)
+
+
+def titlebar(snap, p, n=10):
+ return snap.line(p["y"])[p["x"]:p["x"] + n]
+
+
+def test_a_bel_marks_the_pane_that_rang():
+ l = lay(TWO_PANES)
+ with Session(SH, cols=90, rows=16, layout=l) as s:
+ s.settle(30)
+ panes = s.panes()
+ mine = [p for p in panes if p["focused"]][0]
+ other = [p for p in panes if not p["focused"]][0]
+
+ check("no mark before anything rings",
+ MARK not in titlebar(s.snapshot(), other), "")
+
+ ring(s, other["id"], mine["id"])
+ check("the pane that rang is marked",
+ MARK in titlebar(s.snapshot(), other),
+ repr(titlebar(s.snapshot(), other)))
+ check("and the pane that did not is untouched",
+ MARK not in titlebar(s.snapshot(), mine),
+ repr(titlebar(s.snapshot(), mine)))
+ os.unlink(l)
+
+
+def test_looking_at_it_is_what_puts_it_out():
+ l = lay(TWO_PANES)
+ with Session(SH, cols=90, rows=16, layout=l) as s:
+ s.settle(30)
+ panes = s.panes()
+ mine = [p for p in panes if p["focused"]][0]
+ other = [p for p in panes if not p["focused"]][0]
+
+ ring(s, other["id"], mine["id"])
+ check("rung and unlooked-at", MARK in titlebar(s.snapshot(), other), "")
+
+ s.api("focus", id=other["id"])
+ s.settle(30)
+ check("focusing it clears the mark",
+ MARK not in titlebar(s.snapshot(), other),
+ repr(titlebar(s.snapshot(), other)))
+
+ # It must stay out; a bell is not a thing that comes back on its own.
+ s.api("focus", id=mine["id"])
+ s.settle(30)
+ check("and it stays cleared once answered",
+ MARK not in titlebar(s.snapshot(), other),
+ repr(titlebar(s.snapshot(), other)))
+ os.unlink(l)
+
+
+def test_a_bell_in_another_tab_shows_on_the_strip():
+ """The case the indicator exists for: the pane is not on screen at all."""
+ l = lay(TWO_TABS)
+ with Session(SH, cols=90, rows=16, layout=l) as s:
+ s.settle(30)
+ tabs = s.tabs()
+ here = [p for p in s.panes() if p["focused"]][0]
+ away = [p for p in s.panes() if p["tab"] == 2][0]
+
+ check("the strip is unmarked to start",
+ MARK not in s.snapshot().line(1), repr(s.snapshot().line(1)))
+
+ s.api("select-tab", id=tabs[1]["id"])
+ s.settle(20)
+ s.api("focus", id=away["id"])
+ s.settle(10)
+ s.raw(r"\x07")
+ s.settle(30)
+ s.api("select-tab", id=tabs[0]["id"])
+ s.api("focus", id=here["id"])
+ s.settle(30)
+
+ strip = s.snapshot().line(1)
+ check("the tab holding the rung pane is marked", MARK in strip,
+ repr(strip))
+ check("and the mark sits with that tab's own label",
+ strip.index(MARK) > strip.index("notes"), repr(strip))
+
+ s.api("select-tab", id=tabs[1]["id"])
+ s.settle(30)
+ check("visiting the tab clears it",
+ MARK not in s.snapshot().line(1), repr(s.snapshot().line(1)))
+ os.unlink(l)
+
+
+def test_the_strip_and_the_pane_clear_together():
+ """The acknowledgement happens before the frame is drawn, so the strip --
+ which is painted first -- cannot spend a frame reporting a stale bell."""
+ l = lay(TWO_PANES)
+ with Session(SH, cols=90, rows=16, layout=l) as s:
+ s.settle(30)
+ panes = s.panes()
+ mine = [p for p in panes if p["focused"]][0]
+ other = [p for p in panes if not p["focused"]][0]
+ ring(s, other["id"], mine["id"])
+
+ s.api("focus", id=other["id"])
+ s.settle(30)
+ snap = s.snapshot()
+ check("neither the titlebar nor the strip is left marked",
+ MARK not in titlebar(snap, other) and MARK not in snap.line(1),
+ repr(titlebar(snap, other)) + " / " + repr(snap.line(1)))
+ os.unlink(l)
+
+
+def test_it_can_be_turned_off_and_the_mark_chosen():
+ l = lay(TWO_PANES)
+ off = cfg("bell_indicator false\n")
+ with Session(SH, cols=90, rows=16, config=off, layout=l) as s:
+ s.settle(30)
+ panes = s.panes()
+ mine = [p for p in panes if p["focused"]][0]
+ other = [p for p in panes if not p["focused"]][0]
+ ring(s, other["id"], mine["id"])
+ snap = s.snapshot()
+ check("bell_indicator false says nothing anywhere",
+ MARK not in titlebar(snap, other) and MARK not in snap.line(1),
+ repr(titlebar(snap, other)))
+ os.unlink(off)
+
+ custom = cfg('bell_mark "!"\n')
+ with Session(SH, cols=90, rows=16, config=custom, layout=l) as s:
+ s.settle(30)
+ panes = s.panes()
+ mine = [p for p in panes if p["focused"]][0]
+ other = [p for p in panes if not p["focused"]][0]
+ ring(s, other["id"], mine["id"])
+ check("a chosen mark is the one drawn",
+ "!" in titlebar(s.snapshot(), other),
+ repr(titlebar(s.snapshot(), other)))
+ os.unlink(custom)
+ os.unlink(l)
+
+
+if __name__ == "__main__":
+ test_a_bel_marks_the_pane_that_rang()
+ test_looking_at_it_is_what_puts_it_out()
+ test_a_bell_in_another_tab_shows_on_the_strip()
+ test_the_strip_and_the_pane_clear_together()
+ test_it_can_be_turned_off_and_the_mark_chosen()
+ sys.exit(report())