aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 11:11:11 +0200
committeruser <user@clank>2026-08-15 11:11:11 +0200
commitffd73be0f9fc1d03ed832e5215f9a89f6de1e960 (patch)
treea0e88ea63eeb7c8fb4db6a30531c66109ef54bfd /src
parentsl0ppty: colour the states that mean a pane is not live (diff)
downloadslosh-ffd73be0f9fc1d03ed832e5215f9a89f6de1e960.tar.gz
slosh-ffd73be0f9fc1d03ed832e5215f9a89f6de1e960.tar.xz
slosh-ffd73be0f9fc1d03ed832e5215f9a89f6de1e960.zip
sl0ppty: C-a ? shows every binding you have
A prefix-key multiplexer is unusable until you know its keys, and the only place they were written down was a config file and a README. Now they are one keystroke away, in a pane-shaped modal floating over the layout. It is built from CFG.binds, not from a list of what the defaults are. Rebind something and the sheet says what you bound; unbind it (`bind "-" "none"`) and it stops advertising it; change the prefix and the "C-a then:" line at the top changes with it. A cheatsheet that can disagree with the keyboard is worse than no cheatsheet, and it would disagree the first time anybody edited a config -- so it cannot be a list. Bindings that share an action are merged onto one row: `h ←` is one thing to learn, not two, and the nine tab digits are `1…9` rather than nine rows. Keys are named the way you press them -- `\` and `?` and `H`, not "backslash" and "S-slash" and "shift+h" -- and both spellings still parse as config, so the sheet stays copyable. The frame is a pane's frame, corners and title and close button, because a dialog should wear the chrome of the program it belongs to. It fills its own background: a modal you can read a shell prompt through is one nobody trusts. Two columns when there is room, one when there is not, split at a group boundary so a heading never ends a column alone, and every string clipped at the frame -- a small terminal loses words rather than its border. Any key dismisses it, and that key does nothing else: dismissing a modal must not also run the thing you happened to press. A click anywhere does the same and does not reach what it landed on. `?` is bound twice, to shift+slash and to slash, because whether it arrives with a modifier depends on the outer terminal: as a plain byte there is no shift to be had, and under the kitty keyboard protocol -- which our own client asks for -- there is. Binding one of them is a binding that works on the machine it was written on.
Diffstat (limited to 'src')
-rw-r--r--src/app.c222
-rw-r--r--src/config.c129
-rw-r--r--src/config.h9
3 files changed, 360 insertions, 0 deletions
diff --git a/src/app.c b/src/app.c
index 2a057b6..ab2e425 100644
--- a/src/app.c
+++ b/src/app.c
@@ -245,6 +245,10 @@ struct app {
char *clipboard;
char *clipboard_pending;
+ /* The cheatsheet. An overlay like the finder, but a *modal*: it reads and
+ * is dismissed, it never navigates, so anything you press puts it away. */
+ bool help;
+
/* the pane finder overlay: tabs stop being navigation past about six */
bool finder;
char query[64];
@@ -2987,6 +2991,203 @@ static size_t finder_entries(app_t *a, find_entry_t *out, size_t max) {
return c.n;
}
+/* The cheatsheet: every binding the config has, drawn as a pane that happens
+ * to float.
+ *
+ * Built from CFG.binds rather than from a list of what the defaults are, so a
+ * rebound key shows the key you actually bound and a key you removed is not
+ * advertised. Bindings that share an action are merged onto one row — `h` and
+ * `left` are one thing to learn, not two — and the nine tab digits are one
+ * row rather than nine.
+ */
+typedef struct {
+ const char *group; /* NULL for a heading row */
+ char chord[40];
+ const char *label;
+} help_row_t;
+
+static size_t help_rows(help_row_t *out, size_t cap) {
+ static const char *const GROUPS[] = {"panes", "focus", "size", "tabs",
+ "scroll", "session"};
+ size_t n = 0;
+ for (size_t g = 0; g < sizeof GROUPS / sizeof *GROUPS && n < cap; g++) {
+ bool titled = false;
+ /* Actions in the order the label table lists them, so the sheet reads the
+ * same whatever order a config happened to bind things in. */
+ for (int act = ACT_NONE + 1; act <= ACT_SELECT_TAB_1 && n < cap; act++) {
+ const char *group = config_action_group((action_t)act);
+ const char *label = config_action_label((action_t)act);
+ if (!group || !label || strcmp(group, GROUPS[g]) != 0) continue;
+
+ char chords[40] = {0};
+ size_t used = 0;
+ for (size_t i = 0; i < CFG.nbinds; i++) {
+ 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. */
+ bool tabish = act == ACT_SELECT_TAB_1 && bound >= ACT_SELECT_TAB_1;
+ if (bound != (action_t)act && !tabish) continue;
+ if (tabish && bound != ACT_SELECT_TAB_1 && bound != ACT_SELECT_TAB_1 + 8)
+ continue; /* first and last, shown as a range */
+
+ char one[24];
+ config_chord_name(CFG.binds[i].key, CFG.binds[i].mods, one, sizeof one);
+ int wrote = snprintf(chords + used, sizeof chords - used, "%s%s",
+ used ? (tabish ? "\u2026" : " ") : "", one);
+ if (wrote > 0) used += (size_t)wrote;
+ if (used >= sizeof chords - 8) break;
+ }
+ if (!chords[0]) continue; /* bound to nothing: do not advertise it */
+
+ if (!titled) {
+ out[n++] = (help_row_t){NULL, {0}, GROUPS[g]};
+ titled = true;
+ if (n >= cap) break;
+ }
+ out[n] = (help_row_t){GROUPS[g], {0}, label};
+ snprintf(out[n].chord, sizeof out[n].chord, "%s", chords);
+ n++;
+ }
+ }
+ return n;
+}
+
+/* Draw at most `limit` columns of `text`, cut on a character boundary.
+ *
+ * The box is clamped to the screen, so on a small terminal the rows are wider
+ * than what is left of it -- and a label that runs on writes straight over
+ * the frame it is supposed to be inside. */
+static uint16_t help_text(screen_t *s, uint16_t x, uint16_t y, const char *text,
+ uint16_t limit, color_t fg, color_t bg,
+ uint16_t attrs) {
+ if (!limit) return 0;
+ char buf[256];
+ size_t n = 0, seen = 0;
+ for (const char *p = text; *p && n < sizeof buf - 1; p++) {
+ if (((unsigned char)*p & 0xC0) != 0x80) { /* a new character starts here */
+ if (seen == limit) break;
+ seen++;
+ }
+ buf[n++] = *p;
+ }
+ buf[n] = 0;
+ return screen_text(s, x, y, buf, fg, bg, attrs);
+}
+
+static void draw_help(app_t *a, screen_t *s) {
+ help_row_t rows[64];
+ size_t n = help_rows(rows, 64);
+ if (!n) return;
+
+ /* Two columns, split at a group boundary so a heading never ends a column
+ * with nothing under it. One column when the screen is too narrow. */
+ uint16_t cw[2] = {0, 0};
+ uint16_t kw[2] = {0, 0}; /* the chord column, padded so labels line up */
+ size_t split = n;
+ bool two = s->cols >= 78;
+ if (two) {
+ size_t half = (n + 1) / 2;
+ split = half;
+ while (split < n && rows[split].group) split++; /* to the next heading */
+ if (split >= n) { split = half; while (split > 1 && rows[split].group) split--; }
+ }
+ for (size_t i = 0; i < n; i++) {
+ uint16_t col = (two && i >= split) ? 1 : 0;
+ if (rows[i].group && cells(rows[i].chord) > kw[col])
+ kw[col] = cells(rows[i].chord);
+ }
+ for (size_t i = 0; i < n; i++) {
+ uint16_t col = (two && i >= split) ? 1 : 0;
+ uint16_t want = (uint16_t)(kw[col] + 2 + cells(rows[i].label));
+ if (!rows[i].group) want = (uint16_t)cells(rows[i].label);
+ if (want > cw[col]) cw[col] = want;
+ }
+
+ uint16_t body = (uint16_t)(cw[0] + (two ? cw[1] + 4 : 0));
+ uint16_t w = (uint16_t)(body + 6);
+ size_t left_n = two ? split : n, right_n = two ? n - split : 0;
+ uint16_t lines = (uint16_t)(left_n > right_n ? left_n : right_n);
+ uint16_t h = (uint16_t)(lines + 6);
+ if (w > s->cols) w = s->cols;
+ if (h > s->rows) h = s->rows;
+ uint16_t x = (uint16_t)((s->cols - w) / 2), y = (uint16_t)((s->rows - h) / 2);
+
+ /* Opaque, or the panes underneath read through it: a modal that you can see
+ * a shell prompt through is a modal nobody trusts. */
+ for (uint16_t yy = y; yy < y + h; yy++)
+ for (uint16_t xx = x; xx < x + w; xx++)
+ screen_text(s, xx, yy, " ", NO_COLOR, FINDER_BG, 0);
+
+ /* The same frame a pane wears, for the same reason a dialog wears the
+ * window chrome of its platform: it belongs to this program. */
+ const char *tl = CFG.rounded ? "\u256d" : "\u250c", *tr = CFG.rounded ? "\u256e" : "\u2510";
+ const char *bl = CFG.rounded ? "\u2570" : "\u2514", *br = CFG.rounded ? "\u256f" : "\u2518";
+ uint16_t x1 = (uint16_t)(x + w - 1), y1 = (uint16_t)(y + h - 1);
+ screen_text(s, x, y, tl, FRAME_FOCUS, FINDER_BG, 0);
+ screen_text(s, x1, y, tr, FRAME_FOCUS, FINDER_BG, 0);
+ screen_text(s, x, y1, bl, FRAME_FOCUS, FINDER_BG, 0);
+ screen_text(s, x1, y1, br, FRAME_FOCUS, FINDER_BG, 0);
+ for (uint16_t xx = (uint16_t)(x + 1); xx < x1; xx++) {
+ screen_text(s, xx, y, "\u2500", FRAME_FOCUS, FINDER_BG, 0);
+ screen_text(s, xx, y1, "\u2500", FRAME_FOCUS, FINDER_BG, 0);
+ }
+ for (uint16_t yy = (uint16_t)(y + 1); yy < y1; yy++) {
+ screen_text(s, x, yy, "\u2502", FRAME_FOCUS, FINDER_BG, 0);
+ screen_text(s, x1, yy, "\u2502", FRAME_FOCUS, FINDER_BG, 0);
+ }
+
+ const char *title = " keys ";
+ uint16_t tx = (uint16_t)(x + (w - cells(title)) / 2);
+ screen_text(s, tx, y, title, TITLE_FOCUS, FINDER_BG, ATTR_BOLD);
+
+ /* The close button a pane has, in the place a pane has it. */
+ if (w > 10) {
+ uint16_t bx = (uint16_t)(x1 - 2);
+ bool hot = ptr_on(a, bx, y, 2, 1);
+ screen_text(s, bx, y, CFG.close_mark, hot ? PANE_BTN_HOVER : PANE_BTN,
+ FINDER_BG, hot ? ATTR_BOLD : 0);
+ hit_add(&s->hits, bx, y, 2, 1, "closehelp");
+ }
+
+ /* Everything here is prefix-then-key, and saying so once beats repeating
+ * the prefix on thirty rows. */
+ char lead[80];
+ char pfx[24];
+ config_chord_name(CFG.prefix_key, CFG.prefix_mods, pfx, sizeof pfx);
+ snprintf(lead, sizeof lead, "%s then:", pfx);
+ help_text(s, (uint16_t)(x + 3), (uint16_t)(y + 2), lead,
+ (uint16_t)(w > 6 ? w - 6 : 0), HINT_C, FINDER_BG, 0);
+
+ for (size_t i = 0; i < n; i++) {
+ bool right = two && i >= split;
+ size_t row = right ? i - split : i;
+ if ((uint16_t)(row + 4) >= h - 1) continue;
+ uint16_t rx = (uint16_t)(x + 3 + (right ? cw[0] + 4 : 0));
+ uint16_t ry = (uint16_t)(y + 4 + row);
+
+ /* Everything is cut at the frame, and the left column additionally at the
+ * right one, so a clamped box loses words rather than its border. */
+ uint16_t room = (uint16_t)(x1 > rx + 1 ? x1 - rx - 1 : 0);
+ if (two && !right && cw[0] + 4 < room) room = (uint16_t)(cw[0] + 2);
+
+ if (!rows[i].group) {
+ help_text(s, rx, ry, rows[i].label, room, TITLE_FOCUS, FINDER_BG,
+ ATTR_BOLD);
+ continue;
+ }
+ help_text(s, rx, ry, rows[i].chord, room, TITLE_FOCUS, FINDER_BG, 0);
+ uint16_t lx = (uint16_t)(rx + kw[right ? 1 : 0] + 2);
+ help_text(s, lx, ry, rows[i].label,
+ (uint16_t)(x1 > lx + 1 ? x1 - lx - 1 : 0), FINDER_FG, FINDER_BG,
+ 0);
+ }
+
+ const char *foot = " any key closes this ";
+ if (w > cells(foot) + 4)
+ screen_text(s, (uint16_t)(x + (w - cells(foot)) / 2), y1, foot, HINT_C,
+ FINDER_BG, 0);
+}
+
static void draw_finder(app_t *a, screen_t *s) {
find_entry_t entries[64];
size_t n = finder_entries(a, entries, 64);
@@ -3182,6 +3383,9 @@ void app_compose(app_t *a, screen_t *s) {
draw_min_bar(a, s);
draw_status_line(a, s);
if (a->finder) draw_finder(a, s); /* painted last, so its hits win */
+ /* And the modal on top of even that: it is the only thing that can be
+ * interacted with while it is up, so it owns the topmost hits. */
+ if (a->help) draw_help(a, s);
draw_toasts(a, s); /* and above even that: it is transient */
}
@@ -3638,6 +3842,9 @@ static bool prefix_command(app_t *a, const input_event_t *ev) {
a->query[0] = 0;
a->sel = 0;
return true;
+ case ACT_HELP:
+ a->help = !a->help;
+ return true;
case ACT_DETACH: a->detach = true; return true;
case ACT_QUIT: a->quit = true; return true;
default: return false;
@@ -3664,6 +3871,13 @@ void app_event(app_t *a, const input_event_t *ev) {
finder_key(a, ev);
return;
}
+ /* A cheatsheet is read, not driven: any key puts it away, and that key does
+ * nothing else. Swallowing it is the point -- dismissing a modal should not
+ * also run the thing you happened to press. */
+ if (a->help && ev->kind == EV_KEY) {
+ if (ev->action != KEY_RELEASE) a->help = false;
+ return;
+ }
/* A release can go missing (the pointer leaves the terminal, the client
* detaches mid-drag). Any keystroke ends a drag, so the mouse can never be
@@ -3829,6 +4043,14 @@ void app_event(app_t *a, const input_event_t *ev) {
break; /* a drag owns the mouse until the button comes up */
}
+ /* Any press dismisses the cheatsheet, wherever it lands -- including on
+ * its own close button, which is there because a modal without a way
+ * out that you can *see* is a modal people hunt for the way out of. */
+ if (a->help) {
+ if (ev->maction == MOUSE_PRESS) a->help = false;
+ break;
+ }
+
if (action) do_action(a, action, ev);
break;
}
diff --git a/src/config.c b/src/config.c
index 9ebb202..4406443 100644
--- a/src/config.c
+++ b/src/config.c
@@ -29,6 +29,7 @@ static const struct {
{"prev-tab", ACT_PREV_TAB}, {"finder", ACT_FINDER},
{"detach", ACT_DETACH}, {"quit", ACT_QUIT},
{"literal-prefix", ACT_LITERAL_PREFIX},
+ {"help", ACT_HELP},
};
static const struct {
@@ -344,6 +345,12 @@ void config_defaults(config_t *c) {
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);
+ /* `?` 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);
@@ -387,6 +394,128 @@ void config_free(config_t *c) {
memset(c, 0, sizeof *c);
}
+
+/* ---- naming things, for the cheatsheet ---------------------------------- */
+
+/* Phrases rather than the config's names: `split-cols` is what you write,
+ * "split into columns" is what you are looking for when you have forgotten
+ * which key does it. The order here is the order the list is drawn in. */
+static const struct {
+ action_t action;
+ const char *group;
+ const char *label;
+} ACTION_HELP[] = {
+ {ACT_SPLIT_COLS, "panes", "split into columns"},
+ {ACT_SPLIT_ROWS, "panes", "split into rows"},
+ {ACT_CLOSE_PANE, "panes", "close this pane"},
+ {ACT_RERUN, "panes", "run a finished pane again"},
+ {ACT_ZOOM, "panes", "fill the tab with it"},
+ {ACT_MINIMIZE, "panes", "put it away in the strip"},
+
+ {ACT_FOCUS_LEFT, "focus", "go left"},
+ {ACT_FOCUS_RIGHT, "focus", "go right"},
+ {ACT_FOCUS_UP, "focus", "go up"},
+ {ACT_FOCUS_DOWN, "focus", "go down"},
+ {ACT_FOCUS_NEXT, "focus", "the next pane"},
+ {ACT_FINDER, "focus", "find a pane by name"},
+
+ {ACT_RESIZE_LEFT, "size", "move the boundary left"},
+ {ACT_RESIZE_RIGHT, "size", "move the boundary right"},
+ {ACT_RESIZE_UP, "size", "move the boundary up"},
+ {ACT_RESIZE_DOWN, "size", "move the boundary down"},
+
+ {ACT_NEW_TAB, "tabs", "new tab"},
+ {ACT_NEXT_TAB, "tabs", "next tab"},
+ {ACT_PREV_TAB, "tabs", "previous tab"},
+ {ACT_SELECT_TAB_1, "tabs", "go to that tab"},
+
+ {ACT_SCROLL_UP, "scroll", "up a line"},
+ {ACT_SCROLL_DOWN, "scroll", "down a line"},
+ {ACT_SCROLL_PAGE_UP, "scroll", "up a page"},
+ {ACT_SCROLL_PAGE_DOWN, "scroll", "down a page"},
+ {ACT_SCROLL_TOP, "scroll", "to the oldest line"},
+ {ACT_SCROLL_BOTTOM, "scroll", "back to the present"},
+
+ {ACT_HELP, "session", "this list"},
+ {ACT_DETACH, "session", "detach, leave it running"},
+ {ACT_QUIT, "session", "quit the session"},
+ {ACT_LITERAL_PREFIX, "session", "send the prefix itself"},
+};
+
+const char *config_action_label(action_t a) {
+ for (size_t i = 0; i < sizeof ACTION_HELP / sizeof *ACTION_HELP; i++)
+ if (ACTION_HELP[i].action == a) return ACTION_HELP[i].label;
+ return NULL;
+}
+
+const char *config_action_group(action_t a) {
+ for (size_t i = 0; i < sizeof ACTION_HELP / sizeof *ACTION_HELP; i++)
+ if (ACTION_HELP[i].action == a) return ACTION_HELP[i].group;
+ return NULL;
+}
+
+void config_chord_name(int key, uint16_t mods, char *out, size_t cap) {
+ char base[24] = {0};
+
+ /* Punctuation is written as the character you press, shifted or not: `?` is
+ * a key on the keyboard and "S-slash" is a description of one. Both forms
+ * are still valid in a config, so the sheet stays copyable. */
+ static const struct {
+ int key;
+ const char *plain, *shifted;
+ } PUNCT[] = {
+ {GHOSTTY_KEY_SLASH, "/", "?"}, {GHOSTTY_KEY_BACKSLASH, "\\", "|"},
+ {GHOSTTY_KEY_MINUS, "-", "_"}, {GHOSTTY_KEY_EQUAL, "=", "+"},
+ {GHOSTTY_KEY_COMMA, ",", "<"}, {GHOSTTY_KEY_PERIOD, ".", ">"},
+ {GHOSTTY_KEY_SEMICOLON, ";", ":"}, {GHOSTTY_KEY_QUOTE, "'", "\""},
+ {GHOSTTY_KEY_BRACKET_LEFT, "[", "{"},
+ {GHOSTTY_KEY_BRACKET_RIGHT, "]", "}"},
+ {GHOSTTY_KEY_BACKQUOTE, "`", "~"},
+ };
+ /* Arrows as arrows. The cheatsheet is about the keyboard, and four words
+ * where four glyphs will do costs a column of width on every row. */
+ static const struct {
+ int key;
+ const char *glyph;
+ } ARROWS[] = {
+ {GHOSTTY_KEY_ARROW_LEFT, "\u2190"}, {GHOSTTY_KEY_ARROW_RIGHT, "\u2192"},
+ {GHOSTTY_KEY_ARROW_UP, "\u2191"}, {GHOSTTY_KEY_ARROW_DOWN, "\u2193"},
+ };
+
+ for (size_t i = 0; i < sizeof PUNCT / sizeof *PUNCT; i++)
+ if (PUNCT[i].key == key) {
+ bool shifted = (mods & MOD_SHIFT) != 0;
+ snprintf(base, sizeof base, "%s",
+ shifted ? PUNCT[i].shifted : PUNCT[i].plain);
+ if (shifted) mods &= (uint16_t)~MOD_SHIFT; /* spent on the glyph */
+ }
+ for (size_t i = 0; i < sizeof ARROWS / sizeof *ARROWS; i++)
+ if (ARROWS[i].key == key) snprintf(base, sizeof base, "%s", ARROWS[i].glyph);
+
+ if (!base[0])
+ for (size_t i = 0; i < sizeof NAMED_KEYS / sizeof *NAMED_KEYS; i++)
+ if (NAMED_KEYS[i].key == key)
+ snprintf(base, sizeof base, "%s", NAMED_KEYS[i].name);
+
+ if (!base[0] && key >= GHOSTTY_KEY_A && key <= GHOSTTY_KEY_Z) {
+ char c = (char)('a' + (key - GHOSTTY_KEY_A));
+ /* A shifted letter is written as the capital, because that is the key you
+ * press. The modifier is spent here and not printed again below. */
+ if (mods & MOD_SHIFT) {
+ snprintf(base, sizeof base, "%c", (char)(c - 32));
+ mods &= (uint16_t)~MOD_SHIFT;
+ } else {
+ snprintf(base, sizeof base, "%c", c);
+ }
+ }
+ if (!base[0] && key >= GHOSTTY_KEY_DIGIT_0 && key <= GHOSTTY_KEY_DIGIT_9)
+ snprintf(base, sizeof base, "%c", (char)('0' + (key - GHOSTTY_KEY_DIGIT_0)));
+ if (!base[0]) snprintf(base, sizeof base, "?");
+
+ snprintf(out, cap, "%s%s%s%s", mods & MOD_CTRL ? "C-" : "",
+ mods & MOD_ALT ? "M-" : "", mods & MOD_SHIFT ? "S-" : "", base);
+}
+
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);
diff --git a/src/config.h b/src/config.h
index 0377776..6dd470d 100644
--- a/src/config.h
+++ b/src/config.h
@@ -63,6 +63,7 @@ typedef enum {
ACT_RESIZE_DOWN,
ACT_DETACH,
ACT_QUIT,
+ ACT_HELP,
ACT_LITERAL_PREFIX,
ACT_SELECT_TAB_1, /* .. +8, so digits stay one entry each */
} action_t;
@@ -256,6 +257,14 @@ const char *config_default_path(void);
void config_free(config_t *c);
action_t config_lookup(const config_t *c, int key, uint16_t mods);
+/* 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. */
+void config_chord_name(int key, uint16_t mods, char *out, size_t cap);
+/* A phrase for the cheatsheet, and which group it belongs under. `group` is
+ * one of a fixed set, in the order they should be shown. */
+const char *config_action_label(action_t a);
+const char *config_action_group(action_t a);
/* "ctrl+a", "alt+left", "\\", "f" -> key + mods. False if unparseable. */
bool config_parse_chord(const char *text, int *out_key, uint16_t *out_mods);