aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-22 15:12:02 +0200
committeruser <user@clank>2026-09-22 15:12:02 +0200
commit720aa48e249c0c962e8f73372ceded6092414a6e (patch)
treedb713dc86f1333dab0f509e633077384005562c3 /tests
parentsidebar: air inside it, the way panes have padding (tab_bar_padding) (diff)
downloadslosh-720aa48e249c0c962e8f73372ceded6092414a6e.tar.gz
slosh-720aa48e249c0c962e8f73372ceded6092414a6e.tar.xz
slosh-720aa48e249c0c962e8f73372ceded6092414a6e.zip
osc 5577: `busy`, and a sidebar that shows it (busy_mark)
A status is a line of text, and a line of text cannot say whether it is still true. "building 3/7" reads exactly the same while it builds and ten minutes after it stopped, which is the one thing somebody reading it from another tab wants to know -- and the only workaround available to a program was to write the answer into the words and keep rewriting them. So `busy`: printf '\033]5577;1;status;make test\033\\' printf '\033]5577;1;busy;1\033\\' ... printf '\033]5577;1;busy;0\033\\' A flag of its own rather than a field of `status`, because a status is the *whole* payload after the verb -- `status;a;b;c` has always been the text "a;b;c" -- so anything added to that line would change what every existing sender means, including the five pi extensions D1 promises byte compatibility to. Orthogonal, so a program can leave one line up and toggle the other; anything but 0/false/no/off is on; `clear` takes both; and a pane whose program has gone is never busy, whatever it last claimed, because a spinner turning under a dead tab is a lie you keep looking at. In a sidebar the row turns `tab_status_busy` -- the theme's accent, derived like the other status colours, so no theme needs a line -- and takes a spinner in the column `tab_bar_padding` was already holding blank, so nothing reflows when a program starts or stops working. `busy_mark` is the frames, separated by spaces: one frame is a static mark and costs nothing, several make it turn, and only then does the session keep a frame clock -- via the same `animating` flag an animated shader uses, so the cost lands only while such a row is actually composed, and `""` drops the mark entirely and leaves the colour to say it. The frame is a pure function of the clock, like every other pulse here, so two clients attached to one session show the same one. `panes` now reports `status` and `busy`, which is the scriptable half of the same fact: what a pane says about itself, without reading its screen.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_osc5577.py65
-rw-r--r--tests/test_tab_bar.py132
-rw-r--r--tests/test_themes.py2
3 files changed, 198 insertions, 1 deletions
diff --git a/tests/test_osc5577.py b/tests/test_osc5577.py
index 0a58b5b..a196d56 100644
--- a/tests/test_osc5577.py
+++ b/tests/test_osc5577.py
@@ -7,6 +7,7 @@ ones: a pane's own output is untrusted input.
"""
import sys
+import tempfile
import time
from harness import Session, check, report
@@ -158,6 +159,68 @@ def test_replace_and_clear():
)
+def test_busy_is_its_own_flag():
+ """`busy` says whether what the status describes is still happening -- the
+ one thing the text cannot say about itself. A flag rather than a field of
+ `status`, because a status is the whole payload after the verb and adding
+ to that line would change what every existing sender means."""
+ with Session(shell(), cols=50, rows=8) as s:
+ s.settle()
+ busy = lambda: s.panes()[0]["busy"]
+
+ s.raw(emit_cmd("1;status;make test"))
+ s.settle()
+ check("a status on its own is not busy", not busy(), str(s.panes()[0]))
+ check(
+ "...and the text arrived",
+ s.panes()[0]["status"] == "make test",
+ str(s.panes()[0]),
+ )
+
+ s.raw(emit_cmd("1;busy;1"))
+ s.settle()
+ check("busy;1 sets it", busy(), str(s.panes()[0]))
+ check(
+ "...without touching the text",
+ s.panes()[0]["status"] == "make test",
+ str(s.panes()[0]),
+ )
+
+ s.raw(emit_cmd("1;status;make install"))
+ s.settle()
+ check("a new status leaves the flag alone", busy(), str(s.panes()[0]))
+
+ s.raw(emit_cmd("1;busy;0"))
+ s.settle()
+ check("busy;0 clears it", not busy(), str(s.panes()[0]))
+
+ s.raw(emit_cmd("1;busy"))
+ s.settle()
+ check("busy with nothing after it means busy", busy(), str(s.panes()[0]))
+
+ s.raw(emit_cmd("1;clear"))
+ s.settle()
+ check(
+ "clear takes the flag with the text",
+ not busy() and not s.panes()[0]["status"],
+ str(s.panes()[0]),
+ )
+
+
+def test_a_status_left_by_a_dead_program_is_not_busy():
+ """It describes the past, whatever it last claimed -- and a spinner turning
+ forever under a tab whose program is gone is a lie you keep looking at."""
+ body = 'printf "\\033]5577;1;status;working\\033\\\\"; printf "\\033]5577;1;busy;1\\033\\\\"'
+ keep = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ keep.write('keep_dead "all"\n') # so there is still a pane to ask
+ keep.close()
+ with Session(["/bin/sh", "-c", body], cols=50, rows=8, config=keep.name) as s:
+ s.until(lambda snap: s.panes() and not s.panes()[0]["alive"])
+ p = s.panes()[0]
+ check("the program is gone", not p["alive"], str(p))
+ check("and nothing is still happening", not p["busy"], str(p))
+
+
def test_escaping():
with Session(
emit(
@@ -388,6 +451,8 @@ if __name__ == "__main__":
test_status_and_buttons()
test_click_reports()
test_replace_and_clear()
+ test_busy_is_its_own_flag()
+ test_a_status_left_by_a_dead_program_is_not_busy()
test_escaping()
test_hostile_input()
test_split_across_reads()
diff --git a/tests/test_tab_bar.py b/tests/test_tab_bar.py
index 2c35228..36af795 100644
--- a/tests/test_tab_bar.py
+++ b/tests/test_tab_bar.py
@@ -495,6 +495,134 @@ def test_status_rows_are_told_apart_by_the_slant_not_by_an_indent():
)
+def _busy(s, on=True):
+ s.raw('printf "\\e]5577;1;busy;%s\\x07"\\n' % ("1" if on else "0"))
+
+
+def test_a_busy_status_says_so():
+ """The text of a running `make test` and a finished one is the same text,
+ so the row says it instead: the busy colour, and a mark in the column the
+ padding holds blank."""
+ with Session(SH, cols=90, rows=20, config=LEFT) as s:
+ s.settle(30)
+ _status(s, "make test")
+ snap = s.snapshot()
+ idle = snap.style_at(CX + 1, Y0 + 1)
+ check("a plain status is ambient", idle["fg"] == "#5a6a8f", str(idle))
+ check(
+ "...and nothing sits in the indent",
+ snap.line(Y0 + 1)[CX] == " ",
+ repr(_row(snap, Y0 + 1)),
+ )
+
+ _busy(s)
+ s.settle(30)
+ snap = s.snapshot()
+ hot = snap.style_at(CX + 1, Y0 + 1)
+ check("busy turns the row's own colour", hot["fg"] == "#7aa2f7", str(hot))
+ check(
+ "and a spinner lands in the indent",
+ snap.line(Y0 + 1)[CX] != " ",
+ repr(_row(snap, Y0 + 1)),
+ )
+ check(
+ "...where it moves nothing: the text is where it was",
+ _row(snap, Y0 + 1).index("make test") == 1,
+ repr(_row(snap, Y0 + 1)),
+ )
+
+ _busy(s, False)
+ s.settle(30)
+ snap = s.snapshot()
+ check(
+ "done puts it back",
+ snap.style_at(CX + 1, Y0 + 1)["fg"] == "#5a6a8f"
+ and snap.line(Y0 + 1)[CX] == " ",
+ repr(_row(snap, Y0 + 1)),
+ )
+
+
+def test_a_spinner_costs_a_frame_clock_and_a_mark_does_not():
+ """Several frames turn, one frame is a mark -- and only the turning one is
+ worth repainting the session for, only while it is on screen."""
+ one = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\nbusy_mark "*"\n')
+ with Session(SH, cols=90, rows=20, config=one) as s:
+ s.settle(30)
+ _status(s, "make test")
+ _busy(s)
+ s.settle(30)
+ check(
+ "the static mark is drawn",
+ s.snapshot().line(Y0 + 1)[CX] == "*",
+ repr(_row(s.snapshot(), Y0 + 1)),
+ )
+ check(
+ "and asks for no clock",
+ s.api("deadline")["ms"] == -1,
+ str(s.api("deadline")),
+ )
+
+ with Session(SH, cols=90, rows=20, config=LEFT) as s:
+ s.settle(30)
+ _status(s, "make test")
+ s.settle(30)
+ s.snapshot()
+ check(
+ "an idle sidebar wants no clock either",
+ s.api("deadline")["ms"] == -1,
+ str(s.api("deadline")),
+ )
+ _busy(s)
+ s.settle(30)
+ # The deadline is a property of the frame that was composed, like
+ # `animating` itself -- so ask for a frame before asking when the next
+ # one is wanted.
+ s.snapshot()
+ check(
+ "a spinner on screen asks for one",
+ s.api("deadline")["ms"] > 0,
+ str(s.api("deadline")),
+ )
+ _busy(s, False)
+ s.settle(30)
+ s.snapshot()
+ check(
+ "...and gives it back when the work stops",
+ s.api("deadline")["ms"] == -1,
+ str(s.api("deadline")),
+ )
+
+
+def test_no_busy_mark_leaves_the_colour_to_say_it():
+ quiet = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\nbusy_mark ""\n')
+ with Session(SH, cols=90, rows=20, config=quiet) as s:
+ s.settle(30)
+ _status(s, "make test")
+ _busy(s)
+ s.settle(30)
+ snap = s.snapshot()
+ check("no mark", snap.line(Y0 + 1)[CX] == " ", repr(_row(snap, Y0 + 1)))
+ check(
+ "but the row still says it is running",
+ snap.style_at(CX + 1, Y0 + 1)["fg"] == "#7aa2f7",
+ str(snap.style_at(CX + 1, Y0 + 1)),
+ )
+
+
+def test_the_busy_colour_is_the_themes_own():
+ themed = _cfg(
+ f'tab_bar_side "left"\ntab_bar_width {W}\n'
+ 'theme { tab_status_busy "#ff00ff" }\n'
+ )
+ with Session(SH, cols=90, rows=20, config=themed) as s:
+ s.settle(30)
+ _status(s, "make test")
+ _busy(s)
+ s.settle(30)
+ st = s.snapshot().style_at(CX + 1, Y0 + 1)
+ check("named, it wins", st["fg"] == "#ff00ff", str(st))
+
+
def test_status_rows_take_their_own_theme_colours():
themed = _cfg(
f'tab_bar_side "left"\ntab_bar_width {W}\n'
@@ -1110,6 +1238,10 @@ if __name__ == "__main__":
test_a_theme_can_have_the_bare_tabs_back()
test_status_rows_sit_under_their_tab()
test_status_rows_are_told_apart_by_the_slant_not_by_an_indent()
+ test_a_busy_status_says_so()
+ test_a_spinner_costs_a_frame_clock_and_a_mark_does_not()
+ test_no_busy_mark_leaves_the_colour_to_say_it()
+ test_the_busy_colour_is_the_themes_own()
test_status_rows_take_their_own_theme_colours()
test_an_old_theme_gets_coherent_status_colours_anyway()
test_a_monochrome_theme_stays_monochrome()
diff --git a/tests/test_themes.py b/tests/test_themes.py
index 6710477..d9c67b0 100644
--- a/tests/test_themes.py
+++ b/tests/test_themes.py
@@ -26,7 +26,7 @@ THEMES = sorted(glob.glob(os.path.join(THEMEDIR, "*.kdl")))
# from colours the theme does define (so a theme gets them in its own family
# without a line), and the opt-in ones are unset on purpose, because a band
# behind a row takes a translucent terminal's own background away.
-DERIVED = {"tab_status_fg", "tab_status_stripe", "tab_idle_bg"}
+DERIVED = {"tab_status_fg", "tab_status_stripe", "tab_status_busy", "tab_idle_bg"}
OPT_IN = {"tab_status_bg", "attach_bg"}
# The scrollback search tints: one theme in contrib names them and the rest
# take the compiled-in pair, which reads the same on every palette here.