aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 11:45:56 +0200
committeruser <user@clank>2026-08-15 11:45:56 +0200
commite9dddc549de1ecd068fa152bf735a72d3a3d03c9 (patch)
treed2c9cef53917a0c444bcd05e03343e31487b9fcb
parentsl0ppty: the prefix badge names the key you actually bound (diff)
downloadslosh-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.md20
-rw-r--r--README.md16
-rw-r--r--config/config.kdl21
-rw-r--r--src/app.c32
-rw-r--r--src/config.c151
-rw-r--r--src/config.h13
-rw-r--r--tests/test_direct_keys.py148
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.
diff --git a/README.md b/README.md
index 217e27e..73612ce 100644
--- a/README.md
+++ b/README.md
@@ -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"
+ // }
}
diff --git a/src/app.c b/src/app.c
index 297bf00..d600543 100644
--- a/src/app.c
+++ b/src/app.c
@@ -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())