diff options
| author | user <user@clank> | 2026-08-15 11:45:56 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 11:45:56 +0200 |
| commit | e9dddc549de1ecd068fa152bf735a72d3a3d03c9 (patch) | |
| tree | d2c9cef53917a0c444bcd05e03343e31487b9fcb /tests | |
| parent | sl0ppty: the prefix badge names the key you actually bound (diff) | |
| download | slosh-e9dddc549de1ecd068fa152bf735a72d3a3d03c9.tar.gz slosh-e9dddc549de1ecd068fa152bf735a72d3a3d03c9.tar.xz slosh-e9dddc549de1ecd068fa152bf735a72d3a3d03c9.zip | |
sl0ppty: bindings that need no leader
Every binding needed the prefix first, which is the safe design and the
reason a leader exists: a multiplexer that takes chords away from the
programs inside it is a multiplexer you fight. But it is the user's keyboard.
Somebody who wants ctrl+alt+arrow to move between panes should be allowed to
have it, including the part where that chord stops reaching vim.
keys {
direct {
bind "ctrl+alt+left" "focus-left"
bind "ctrl+alt+right" "focus-right"
}
}
A block rather than a flag on each line, because these take a chord away from
every program in every pane, and that is worth seeing at a glance -- and
worth having somewhere to write the warning down, which the config now does
in full: prefer chords no program wants, a bare letter certainly is not one,
`none` gives it back.
Three ordering decisions hold it together, none of them obvious from reading
the code, all three tested:
- the direct lookup happens *last* in the key path -- after the rename editor,
the finder, the cheatsheet, and the prefix itself. An overlay still owns the
keyboard, and a direct binding cannot shadow the leader, which would make
the leader unusable with no way back. There is a test that tries exactly
that and finds the prefix still working.
- `direct` is part of a binding's identity rather than a property of it. `x`
after the leader and `x` on its own are two bindings; binding one must not
silently redefine the other.
- the after-the-leader lookup falls through to direct bindings, so pressing
the prefix first never makes a binding stop working. A rule you have to
keep track of is a rule that will catch you out.
The cheatsheet lists them under "without the leader", apart from the rest,
because the caption at the top of it says "<prefix> then:" and these are
precisely the ones that is not true of.
The tests state the bargain rather than just the mechanism: the chord fires
with no leader, and what it fires on, the program never sees -- asserted with
`cat -v`, so a keystroke arriving in the pane is visible on screen rather
than an invisible control byte that would prove nothing either way.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_direct_keys.py | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/tests/test_direct_keys.py b/tests/test_direct_keys.py new file mode 100644 index 0000000..3929310 --- /dev/null +++ b/tests/test_direct_keys.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Bindings that fire without the leader. + +Every binding used to need the prefix first, which is the safe design: a +multiplexer that takes chords away from the programs inside it is a +multiplexer you fight. But it is the user's keyboard, and somebody who wants +ctrl+alt+arrow to move between panes should be allowed to have it — including +the part where that chord no longer reaches anything else. + +So the deal is exactly this, and it is what these tests pin: a `direct` block +fires without the prefix, and what it fires on, nothing else sees. +""" +import sys +import tempfile + +from harness import Session, check, report + +# `cat -v` renders control bytes visibly, so "did the program see it" is a +# question the screen can answer. +SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat -v'] + +C_M_RIGHT = r"\e[1;7C" # ctrl+alt+right +C_M_LEFT = r"\e[1;7D" # ctrl+alt+left, deliberately left unbound + + +def cfg(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +DIRECT = cfg('keys {\n' + ' direct {\n' + ' bind "ctrl+alt+right" "split-cols"\n' + ' }\n' + '}\n') + + +def test_it_fires_with_no_leader(): + with Session(SH, cols=80, rows=14, config=DIRECT) as s: + s.settle() + check("one pane to start", len(s.panes()) == 1) + s.send(C_M_RIGHT) + s.settle() + check("the chord split the pane on its own", len(s.panes()) == 2, + str(s.panes())) + + +def test_what_it_fires_on_the_program_never_sees(): + """The footgun, stated: a direct binding takes that chord away.""" + with Session(SH, cols=80, rows=14, config=DIRECT) as s: + s.settle() + s.send(C_M_RIGHT) + s.settle(40) + check("the pane was not sent the keystroke", + "1;7C" not in s.snapshot().screen(), s.snapshot().screen()[:300]) + + +def test_everything_else_still_reaches_the_program(): + with Session(SH, cols=80, rows=14, config=DIRECT) as s: + s.settle() + s.send(C_M_LEFT) + snap = s.until(lambda sn: "1;7D" in sn.screen()) + check("an unbound chord goes straight through", + "1;7D" in snap.screen(), snap.screen()[:300]) + check("and did nothing else", len(s.panes()) == 1, str(s.panes())) + + +def test_it_also_works_after_the_leader(): + """Pressing the prefix first must not make a binding stop working — that + would be a rule nobody could keep track of.""" + with Session(SH, cols=80, rows=14, config=DIRECT) as s: + s.settle() + s.send(r"\x01" + C_M_RIGHT) + s.settle() + check("the leader then the chord does the same thing", + len(s.panes()) == 2, str(s.panes())) + + +def test_prefixed_bindings_are_unaffected(): + with Session(SH, cols=80, rows=14, config=DIRECT) as s: + s.settle() + s.send(r"\x01\\") + s.settle() + check("C-a \\ still splits", len(s.panes()) == 2, str(s.panes())) + + +def test_the_leader_itself_cannot_be_stolen(): + """A direct binding on the prefix key would make the prefix unusable, and + the ordering has to guarantee it cannot.""" + conf = cfg('keys {\n' + ' prefix "ctrl+a"\n' + ' direct { bind "ctrl+a" "close-pane" }\n' + '}\n') + with Session(SH, cols=80, rows=14, config=conf) as s: + s.settle() + s.send(r"\x01") + s.settle() + check("the prefix is still the prefix", "C-a" in s.snapshot().line(1), + s.snapshot().line(1)) + check("and the pane was not closed", len(s.panes()) == 1, str(s.panes())) + + +def test_an_overlay_still_owns_the_keyboard(): + """While a modal is up, a direct binding must not fire underneath it.""" + with Session(SH, cols=92, rows=28, config=DIRECT) as s: + s.settle() + s.send(r"\x01?") # the cheatsheet + s.settle() + s.send(C_M_RIGHT) + s.settle() + check("the modal swallowed it", len(s.panes()) == 1, str(s.panes())) + check("and closed, as any key does", + "split into columns" not in s.snapshot().screen()) + + +def test_the_cheatsheet_lists_them_apart(): + with Session(SH, cols=96, rows=30, config=DIRECT) as s: + s.settle() + s.send(r"\x01?") + s.settle() + screen = s.snapshot().screen() + check("under a heading of their own", + "without the leader" in screen, screen) + check("named the way you press them", "C-M-→" in screen, screen) + + +def test_none_unbinds_a_direct_binding(): + conf = cfg('keys {\n' + ' direct {\n' + ' bind "ctrl+alt+right" "split-cols"\n' + ' bind "ctrl+alt+right" "none"\n' + ' }\n' + '}\n') + with Session(SH, cols=80, rows=14, config=conf) as s: + s.settle() + s.send(C_M_RIGHT) + snap = s.until(lambda sn: "1;7C" in sn.screen()) + check("it does not fire", len(s.panes()) == 1, str(s.panes())) + check("and the chord goes back to the program", "1;7C" in snap.screen(), + snap.screen()[:300]) + + +for name, fn in sorted(list(globals().items())): + if name.startswith("test_"): + fn() +sys.exit(report()) |
