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 | |
| 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.
| -rw-r--r-- | HANDOVER.md | 20 | ||||
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | config/config.kdl | 21 | ||||
| -rw-r--r-- | src/app.c | 32 | ||||
| -rw-r--r-- | src/config.c | 151 | ||||
| -rw-r--r-- | src/config.h | 13 | ||||
| -rw-r--r-- | tests/test_direct_keys.py | 148 |
7 files changed, 344 insertions, 57 deletions
diff --git a/HANDOVER.md b/HANDOVER.md index 1950872..ea82bb5 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -235,6 +235,26 @@ repeating: background differs from the configured one shifts slightly. Documented next to `modal_scrim` rather than hidden. +## Leader-less bindings + +`keys { direct { bind ... } }` fires without the prefix. Three ordering facts +hold it together, and all three are tested because none is obvious from the +code: + +- direct lookup happens **last** in the key path, after the rename editor, the + finder, the cheatsheet and the prefix itself. So an overlay still owns the + keyboard, and a direct binding cannot shadow the leader — which would make + the leader unusable with no way back. +- `direct` is part of a binding's **identity**, not a property of it: `x` + after the leader and `x` on its own are two bindings, and binding one must + not silently redefine the other. +- `config_lookup()` (the after-the-leader path) falls through to direct + bindings, so pressing the prefix first never makes a binding stop working. + A rule anybody has to keep track of is a rule that will surprise them. + +The cheatsheet lists them in their own section, because the caption at the top +says "<prefix> then:" and these are exactly the ones that is not true of. + ## Things left on the table - **A `reload` keybinding.** The config watcher made it less pressing. @@ -65,6 +65,22 @@ included, follows what you bound: keys { prefix "ctrl+b" } // or ctrl+space, or alt+x, or ... ``` +Or skip the leader entirely for the ones you use constantly: + +```kdl +keys { + direct { + bind "ctrl+alt+left" "focus-left" + bind "ctrl+alt+right" "focus-right" + } +} +``` + +Those fire the moment you press them — which also means the chord is gone from +every program in every pane, because it has to be. It is a footgun and it is +yours; the cheatsheet lists them apart, under "without the leader", so the +`C-a then:` caption above stays true. + ### The mouse works properly - **Click a pane's border to split toward it** — the side you click is the side diff --git a/config/config.kdl b/config/config.kdl index 5e26c8e..c67519b 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -494,4 +494,25 @@ keys { bind "end" "scroll-bottom" bind "d" "detach" bind "q" "quit" + + // ── bindings that need no leader ──────────────────────────────────────── + // + // Anything in here fires the moment you press it, with no prefix first. + // That also means the chord is *gone* from every program in every pane: + // vim will never see it, your shell will never see it, and the only sign + // will be that the key does not work in there any more. + // + // It is a footgun and it is yours: this is your keyboard, and a + // multiplexer that refuses to get out of the way is its own kind of + // wrong. Prefer chords no program wants -- ctrl+alt+something is usually + // safe, a bare letter certainly is not. + // + // `none` gives a chord back, the same as anywhere else. + // + // direct { + // bind "ctrl+alt+left" "focus-left" + // bind "ctrl+alt+right" "focus-right" + // bind "ctrl+alt+up" "focus-up" + // bind "ctrl+alt+down" "focus-down" + // } } @@ -3037,6 +3037,7 @@ static size_t help_rows(help_row_t *out, size_t cap) { char chords[40] = {0}; size_t used = 0; for (size_t i = 0; i < CFG.nbinds; i++) { + if (CFG.binds[i].direct) continue; /* listed on their own, below */ action_t bound = CFG.binds[i].action; /* The nine tab digits are one row: nine of them is a table, not a * thing to learn. */ @@ -3064,6 +3065,23 @@ static size_t help_rows(help_row_t *out, size_t cap) { n++; } } + + /* Bindings that need no leader, in their own section because the caption at + * the top of the sheet says "<prefix> then:" and these are the ones that is + * not true of. Listed in the order they were bound rather than by action: + * they are a short, deliberate list somebody wrote by hand. */ + if (config_has_direct(&CFG) && n < cap) { + out[n++] = (help_row_t){NULL, {0}, "without the leader"}; + for (size_t i = 0; i < CFG.nbinds && n < cap; i++) { + if (!CFG.binds[i].direct) continue; + const char *label = config_action_label(CFG.binds[i].action); + if (!label) continue; /* unbound with `none`, or nothing to say */ + out[n] = (help_row_t){"direct", {0}, label}; + config_chord_name(CFG.binds[i].key, CFG.binds[i].mods, out[n].chord, + sizeof out[n].chord); + n++; + } + } return n; } @@ -3975,6 +3993,20 @@ void app_event(app_t *a, const input_event_t *ev) { a->prefix = true; return; } + + /* Bindings that need no leader. Last, so the leader itself and every + * overlay that owns the keyboard have already had their say -- a direct + * binding must not fire while you are typing a pane's new name, and it + * must never shadow the prefix. + * + * Everything below this line goes to the program in the pane, so a chord + * bound here is a chord that program can no longer see. That is the deal, + * it is opt-in, and it is the user's keyboard. */ + action_t direct = config_lookup_direct(&CFG, ev->key, mods); + if (direct != ACT_NONE && direct != ACT_LITERAL_PREFIX) { + prefix_command(a, ev); + return; + } } switch (ev->kind) { diff --git a/src/config.c b/src/config.c index bdff0a6..941d6e9 100644 --- a/src/config.c +++ b/src/config.c @@ -186,14 +186,19 @@ static size_t parse_shader_list(config_t *c, const kdl_node_t *node, return n; } -static void bind_add(config_t *c, int key, uint16_t mods, action_t action) { +/* `direct` is part of the identity, not a property of it: `x` after the leader + * and `x` on its own are two different bindings, and binding one must not + * silently redefine the other. */ +static void bind_add(config_t *c, int key, uint16_t mods, action_t action, + bool direct) { for (size_t i = 0; i < c->nbinds; i++) - if (c->binds[i].key == key && c->binds[i].mods == mods) { + if (c->binds[i].key == key && c->binds[i].mods == mods && + c->binds[i].direct == direct) { c->binds[i].action = action; /* a later binding replaces an earlier one */ return; } c->binds = realloc(c->binds, (c->nbinds + 1) * sizeof *c->binds); - c->binds[c->nbinds++] = (binding_t){key, mods, action}; + c->binds[c->nbinds++] = (binding_t){key, mods, action, direct}; } void config_defaults(config_t *c) { @@ -355,47 +360,48 @@ void config_defaults(config_t *c) { c->prefix_key = GHOSTTY_KEY_A; c->prefix_mods = MOD_CTRL; - bind_add(c, GHOSTTY_KEY_BACKSLASH, 0, ACT_SPLIT_COLS); - bind_add(c, GHOSTTY_KEY_MINUS, 0, ACT_SPLIT_ROWS); - bind_add(c, GHOSTTY_KEY_X, 0, ACT_CLOSE_PANE); + bind_add(c, GHOSTTY_KEY_BACKSLASH, 0, ACT_SPLIT_COLS, false); + bind_add(c, GHOSTTY_KEY_MINUS, 0, ACT_SPLIT_ROWS, false); + bind_add(c, GHOSTTY_KEY_X, 0, ACT_CLOSE_PANE, false); /* `?` twice, because whether it arrives with shift depends on the outer * terminal: as a plain byte there is no modifier to be had, and under the * kitty keyboard protocol (which the client asks for) there is. Binding one * of them is a binding that works on the author's machine. */ - bind_add(c, GHOSTTY_KEY_SLASH, MOD_SHIFT, ACT_HELP); - bind_add(c, GHOSTTY_KEY_SLASH, 0, ACT_HELP); - bind_add(c, GHOSTTY_KEY_R, 0, ACT_RERUN); - bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM); - bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE); - bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT); - bind_add(c, GHOSTTY_KEY_L, 0, ACT_FOCUS_RIGHT); - bind_add(c, GHOSTTY_KEY_K, 0, ACT_FOCUS_UP); - bind_add(c, GHOSTTY_KEY_J, 0, ACT_FOCUS_DOWN); - bind_add(c, GHOSTTY_KEY_ARROW_LEFT, 0, ACT_FOCUS_LEFT); - bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, 0, ACT_FOCUS_RIGHT); - bind_add(c, GHOSTTY_KEY_ARROW_UP, 0, ACT_FOCUS_UP); - bind_add(c, GHOSTTY_KEY_ARROW_DOWN, 0, ACT_FOCUS_DOWN); - bind_add(c, GHOSTTY_KEY_O, 0, ACT_FOCUS_NEXT); - bind_add(c, GHOSTTY_KEY_H, MOD_SHIFT, ACT_RESIZE_LEFT); - bind_add(c, GHOSTTY_KEY_L, MOD_SHIFT, ACT_RESIZE_RIGHT); - bind_add(c, GHOSTTY_KEY_K, MOD_SHIFT, ACT_RESIZE_UP); - bind_add(c, GHOSTTY_KEY_J, MOD_SHIFT, ACT_RESIZE_DOWN); - bind_add(c, GHOSTTY_KEY_ARROW_LEFT, MOD_SHIFT, ACT_RESIZE_LEFT); - bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, MOD_SHIFT, ACT_RESIZE_RIGHT); - bind_add(c, GHOSTTY_KEY_ARROW_UP, MOD_SHIFT, ACT_RESIZE_UP); - bind_add(c, GHOSTTY_KEY_ARROW_DOWN, MOD_SHIFT, ACT_RESIZE_DOWN); - bind_add(c, GHOSTTY_KEY_C, 0, ACT_NEW_TAB); - bind_add(c, GHOSTTY_KEY_N, 0, ACT_NEXT_TAB); - bind_add(c, GHOSTTY_KEY_P, 0, ACT_PREV_TAB); - bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER); - bind_add(c, GHOSTTY_KEY_PAGE_UP, 0, ACT_SCROLL_PAGE_UP); - bind_add(c, GHOSTTY_KEY_PAGE_DOWN, 0, ACT_SCROLL_PAGE_DOWN); - bind_add(c, GHOSTTY_KEY_HOME, 0, ACT_SCROLL_TOP); - bind_add(c, GHOSTTY_KEY_END, 0, ACT_SCROLL_BOTTOM); - bind_add(c, GHOSTTY_KEY_D, 0, ACT_DETACH); - bind_add(c, GHOSTTY_KEY_Q, 0, ACT_QUIT); + bind_add(c, GHOSTTY_KEY_SLASH, MOD_SHIFT, ACT_HELP, false); + bind_add(c, GHOSTTY_KEY_SLASH, 0, ACT_HELP, false); + bind_add(c, GHOSTTY_KEY_R, 0, ACT_RERUN, false); + bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM, false); + bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE, false); + bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT, false); + bind_add(c, GHOSTTY_KEY_L, 0, ACT_FOCUS_RIGHT, false); + bind_add(c, GHOSTTY_KEY_K, 0, ACT_FOCUS_UP, false); + bind_add(c, GHOSTTY_KEY_J, 0, ACT_FOCUS_DOWN, false); + bind_add(c, GHOSTTY_KEY_ARROW_LEFT, 0, ACT_FOCUS_LEFT, false); + bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, 0, ACT_FOCUS_RIGHT, false); + bind_add(c, GHOSTTY_KEY_ARROW_UP, 0, ACT_FOCUS_UP, false); + bind_add(c, GHOSTTY_KEY_ARROW_DOWN, 0, ACT_FOCUS_DOWN, false); + bind_add(c, GHOSTTY_KEY_O, 0, ACT_FOCUS_NEXT, false); + bind_add(c, GHOSTTY_KEY_H, MOD_SHIFT, ACT_RESIZE_LEFT, false); + bind_add(c, GHOSTTY_KEY_L, MOD_SHIFT, ACT_RESIZE_RIGHT, false); + bind_add(c, GHOSTTY_KEY_K, MOD_SHIFT, ACT_RESIZE_UP, false); + bind_add(c, GHOSTTY_KEY_J, MOD_SHIFT, ACT_RESIZE_DOWN, false); + bind_add(c, GHOSTTY_KEY_ARROW_LEFT, MOD_SHIFT, ACT_RESIZE_LEFT, false); + bind_add(c, GHOSTTY_KEY_ARROW_RIGHT, MOD_SHIFT, ACT_RESIZE_RIGHT, false); + bind_add(c, GHOSTTY_KEY_ARROW_UP, MOD_SHIFT, ACT_RESIZE_UP, false); + bind_add(c, GHOSTTY_KEY_ARROW_DOWN, MOD_SHIFT, ACT_RESIZE_DOWN, false); + bind_add(c, GHOSTTY_KEY_C, 0, ACT_NEW_TAB, false); + bind_add(c, GHOSTTY_KEY_N, 0, ACT_NEXT_TAB, false); + bind_add(c, GHOSTTY_KEY_P, 0, ACT_PREV_TAB, false); + bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER, false); + bind_add(c, GHOSTTY_KEY_PAGE_UP, 0, ACT_SCROLL_PAGE_UP, false); + bind_add(c, GHOSTTY_KEY_PAGE_DOWN, 0, ACT_SCROLL_PAGE_DOWN, false); + bind_add(c, GHOSTTY_KEY_HOME, 0, ACT_SCROLL_TOP, false); + bind_add(c, GHOSTTY_KEY_END, 0, ACT_SCROLL_BOTTOM, false); + bind_add(c, GHOSTTY_KEY_D, 0, ACT_DETACH, false); + bind_add(c, GHOSTTY_KEY_Q, 0, ACT_QUIT, false); for (int i = 0; i < 9; i++) - bind_add(c, GHOSTTY_KEY_DIGIT_1 + i, 0, (action_t)(ACT_SELECT_TAB_1 + i)); + bind_add(c, GHOSTTY_KEY_DIGIT_1 + i, 0, + (action_t)(ACT_SELECT_TAB_1 + i), false); } void config_free(config_t *c) { @@ -532,12 +538,31 @@ void config_chord_name(int key, uint16_t mods, char *out, size_t cap) { action_t config_lookup(const config_t *c, int key, uint16_t mods) { /* caps/num lock must not make a binding stop working */ mods &= (uint16_t)(MOD_SHIFT | MOD_CTRL | MOD_ALT | MOD_SUPER); + /* Prefixed bindings first, then direct ones: after the leader, everything + * that is bound works, so a chord that fires on its own does not stop + * firing because you happened to press the leader before it. */ for (size_t i = 0; i < c->nbinds; i++) - if (c->binds[i].key == key && c->binds[i].mods == mods) + if (!c->binds[i].direct && c->binds[i].key == key && + c->binds[i].mods == mods) + return c->binds[i].action; + return config_lookup_direct(c, key, mods); +} + +action_t config_lookup_direct(const config_t *c, int key, uint16_t mods) { + mods &= (uint16_t)(MOD_SHIFT | MOD_CTRL | MOD_ALT | MOD_SUPER); + for (size_t i = 0; i < c->nbinds; i++) + if (c->binds[i].direct && c->binds[i].key == key && + c->binds[i].mods == mods) return c->binds[i].action; return ACT_NONE; } +bool config_has_direct(const config_t *c) { + for (size_t i = 0; i < c->nbinds; i++) + if (c->binds[i].direct && c->binds[i].action != ACT_NONE) return true; + return false; +} + static action_t action_by_name(const char *name) { for (size_t i = 0; i < sizeof ACTIONS / sizeof *ACTIONS; i++) if (strcmp(ACTIONS[i].name, name) == 0) return ACTIONS[i].action; @@ -773,25 +798,37 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { snprintf(err, errcap, "bad prefix: %s", pfx); } } + /* `bind` under `keys` needs the leader; `bind` under `keys { direct { } }` + * does not. A block rather than a property on each line, because these + * take a chord away from every program in every pane and that is worth + * being able to see at a glance — and worth having somewhere to write the + * warning down. */ for (size_t i = 0; i < keys->nkids; i++) { - const kdl_node_t *b = keys->kids[i]; - if (strcmp(b->name, "bind") != 0) continue; - const char *chord = kdl_arg(b, 0, NULL); - const char *act = kdl_arg(b, 1, NULL); - int k; - uint16_t m; - if (!chord || !act || !config_parse_chord(chord, &k, &m)) { - if (err && !err[0]) - snprintf(err, errcap, "line %d: bad binding", b->line); - continue; - } - action_t a = action_by_name(act); - if (a == ACT_NONE && strcmp(act, "none") != 0) { - if (err && !err[0]) - snprintf(err, errcap, "line %d: unknown action %s", b->line, act); - continue; + const kdl_node_t *node = keys->kids[i]; + bool direct = strcmp(node->name, "direct") == 0; + if (!direct && strcmp(node->name, "bind") != 0) continue; + + size_t count = direct ? node->nkids : 1; + for (size_t j = 0; j < count; j++) { + const kdl_node_t *b = direct ? node->kids[j] : node; + if (!b || strcmp(b->name, "bind") != 0) continue; + const char *chord = kdl_arg(b, 0, NULL); + const char *act = kdl_arg(b, 1, NULL); + int k; + uint16_t m; + if (!chord || !act || !config_parse_chord(chord, &k, &m)) { + if (err && !err[0]) + snprintf(err, errcap, "line %d: bad binding", b->line); + continue; + } + action_t a = action_by_name(act); + if (a == ACT_NONE && strcmp(act, "none") != 0) { + if (err && !err[0]) + snprintf(err, errcap, "line %d: unknown action %s", b->line, act); + continue; + } + bind_add(c, k, m, a, direct); } - bind_add(c, k, m, a); } } diff --git a/src/config.h b/src/config.h index 889c16b..c093f93 100644 --- a/src/config.h +++ b/src/config.h @@ -72,6 +72,11 @@ typedef struct { int key; /* GhosttyKey */ uint16_t mods; /* MOD_* */ action_t action; + /* Fires without the leader, taking that chord away from every program in + * every pane. Deliberately possible and deliberately opt-in: it is the + * user's terminal and their keyboard, and a multiplexer that refuses to get + * out of the way is its own kind of wrong. */ + bool direct; } binding_t; typedef enum { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } align_t; @@ -270,7 +275,15 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap); const char *config_default_path(void); void config_free(config_t *c); +/* What a chord does after the leader. Direct bindings answer here too, so + * pressing the leader first never makes a binding stop working. */ action_t config_lookup(const config_t *c, int key, uint16_t mods); +/* What a chord does on its own, with no leader pressed. ACT_NONE for the + * overwhelming majority of keys, which is what lets them reach the pane. */ +action_t config_lookup_direct(const config_t *c, int key, uint16_t mods); +/* Whether any binding fires without the leader, so the cheatsheet knows + * whether it has a section to draw. */ +bool config_has_direct(const config_t *c); /* The reverse of config_parse_chord: what to call a binding on screen. A * shifted letter is written the way you would type it ("H"), because "S-h" is * a description of a keystroke rather than a keystroke. */ 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()) |
