diff options
| author | user <user@clank> | 2026-09-22 15:12:02 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-22 15:12:02 +0200 |
| commit | 720aa48e249c0c962e8f73372ceded6092414a6e (patch) | |
| tree | db713dc86f1333dab0f509e633077384005562c3 /tests | |
| parent | sidebar: air inside it, the way panes have padding (tab_bar_padding) (diff) | |
| download | slosh-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.py | 65 | ||||
| -rw-r--r-- | tests/test_tab_bar.py | 132 | ||||
| -rw-r--r-- | tests/test_themes.py | 2 |
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. |
