diff options
| author | user <user@clank> | 2026-08-15 11:11:11 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 11:11:11 +0200 |
| commit | ffd73be0f9fc1d03ed832e5215f9a89f6de1e960 (patch) | |
| tree | a0e88ea63eeb7c8fb4db6a30531c66109ef54bfd /src | |
| parent | sl0ppty: colour the states that mean a pane is not live (diff) | |
| download | slosh-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.c | 222 | ||||
| -rw-r--r-- | src/config.c | 129 | ||||
| -rw-r--r-- | src/config.h | 9 |
3 files changed, 360 insertions, 0 deletions
@@ -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); |
