aboutsummaryrefslogtreecommitdiffstats
path: root/src/app_ui.c
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-19 13:47:10 +0200
committeruser <user@clank>2026-08-19 13:47:10 +0200
commitffaf9ff9db77573612317882b96158b5c7ba9655 (patch)
treeb8371eaa03be35bd7ba23e70c7ae8421c0387440 /src/app_ui.c
parentslosh: defaults most people can live in, said by shaders (diff)
downloadslosh-ffaf9ff9db77573612317882b96158b5c7ba9655.tar.gz
slosh-ffaf9ff9db77573612317882b96158b5c7ba9655.tar.xz
slosh-ffaf9ff9db77573612317882b96158b5c7ba9655.zip
slosh: split app.c into five units, mechanically
app.c was 7k lines wearing section markers; now the markers are files, cut exactly along them, no behaviour change: app.c the session core: config in force, pane and tab lifecycle, walking, input routing, introspection app_layout.c layout, tree edits, resizing, equalising, rotating, focus app_session.c layout files, checking, workspaces, dumping a session app_draw.c drawing, the shader passes, kitty graphics app_ui.c the finder, modals, the palette, the project picker, renaming include/app_internal.h carries what the five share: the tree/tab/app structs, CFG and the palette macros, and the 50-odd formerly static functions the cut made cross-unit, declared where they are defined. They keep their names; only the five units see the header. One test moved with the code: test_portable_layouts' staleness check greps the source for the layouts section, which now lives in app_session.c. Mechanical, so blame for anything in the four new files continues in app.c before this commit.
Diffstat (limited to 'src/app_ui.c')
-rw-r--r--src/app_ui.c1008
1 files changed, 1008 insertions, 0 deletions
diff --git a/src/app_ui.c b/src/app_ui.c
new file mode 100644
index 0000000..021c282
--- /dev/null
+++ b/src/app_ui.c
@@ -0,0 +1,1008 @@
+/* The finder, modals, the palette, the project picker, renaming. Split from app.c. */
+#define _GNU_SOURCE
+#include "app.h"
+
+#include <ghostty/vt.h>
+#include <ctype.h>
+#include <stdarg.h>
+#include <time.h>
+#include <sys/stat.h>
+#include <unistd.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "expr.h"
+#include "json.h"
+#include "version.h"
+#include "graphics.h"
+#include "kdl.h"
+#include "app_internal.h"
+
+/* ---- the pane finder ---------------------------------------------------- */
+
+typedef struct {
+ node_t *node;
+ size_t tab;
+} find_entry_t;
+
+struct find_collect {
+ app_t *a;
+ const char *query;
+ find_entry_t *out;
+ size_t n, max;
+};
+
+static bool ci_contains(const char *hay, const char *needle) {
+ if (!*needle) return true;
+ size_t nl = strlen(needle);
+ for (const char *p = hay; *p; p++) {
+ size_t i = 0;
+ while (i < nl && p[i] &&
+ tolower((unsigned char)p[i]) == tolower((unsigned char)needle[i]))
+ i++;
+ if (i == nl) return true;
+ }
+ return false;
+}
+
+static void find_cb(node_t *n, void *ud) {
+ struct find_collect *c = ud;
+ if (c->n >= c->max) return;
+ char hay[512];
+ size_t ti = tab_of(c->a, n);
+ snprintf(hay, sizeof hay, "%s %s %s", pane_title(n->pane), n->purpose,
+ ti == (size_t)-1 ? "" : c->a->tabs[ti].name);
+ if (!ci_contains(hay, c->query)) return;
+ c->out[c->n].node = n;
+ c->out[c->n].tab = ti;
+ c->n++;
+}
+
+static size_t finder_entries(app_t *a, find_entry_t *out, size_t max) {
+ struct find_collect c = {a, a->query, out, 0, max};
+ walk_all(a, find_cb, &c);
+ 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, or for the blank row above one */
+ char chord[40];
+ const char *label; /* NULL for the blank row */
+} help_row_t;
+
+static size_t help_rows(help_row_t *out, size_t cap) {
+ static const char *const GROUPS[] = {"panes", "focus", "size", "tabs",
+ "projects", "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++) {
+ 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. */
+ 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) {
+ /* A blank line above every heading but the first: the caption at the
+ * top already separates that one, and a sheet whose sections run into
+ * each other is a wall of rows to scan rather than five short lists. */
+ if (n && n < cap) out[n++] = (help_row_t){NULL, {0}, NULL};
+ 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++;
+ }
+ }
+
+ /* 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) {
+ if (n && n < cap) out[n++] = (help_row_t){NULL, {0}, NULL};
+ 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;
+}
+
+/* ---- modals -------------------------------------------------------------
+ *
+ * A surface that floats over the layout: everything behind it is pushed back,
+ * it is opaque, it wears a pane's frame so it plainly belongs to this program,
+ * and it owns the topmost hits while it is up. The cheatsheet is the first
+ * one; the shape is here rather than inside it so the second one costs a
+ * function call and looks the same.
+ */
+
+/* Push the whole screen back, so what floats reads as being in front.
+ *
+ * This is the shader pass, aimed at the screen instead of a pane: the same
+ * dim, the same materialisation of "terminal default" colours (without which
+ * the chrome would not darken at all, since most of it has no colour of its
+ * own). Nothing about it is special-cased, which is the nice part -- a scrim
+ * is just a colour pass with everything in its rect. */
+static void draw_scrim(app_t *a, screen_t *s) {
+ if (!CFG.modal_scrim) return;
+ shader_t dim;
+ if (!shader_make(&dim, "dim", (color_t){0}, CFG.modal_scrim)) return;
+ shade_ctx_t base = {
+ .now_ms = now_ms_(),
+ .default_fg = CFG.default_fg,
+ .default_bg = CFG.default_bg,
+ };
+ shade_apply(s, &dim, 1, (rect_t){0, 0, s->cols, s->rows}, NULL, &base);
+}
+
+/* The frame every modal wears: an opaque box with a pane's corners, a title
+ * in the top rule and a close button where a pane keeps one. Returns the rect
+ * *inside* it, which is all a caller should care about. */
+static rect_t modal_frame(app_t *a, screen_t *s, uint16_t w, uint16_t h,
+ const char *title, const char *close_action) {
+ 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);
+ uint16_t x1 = (uint16_t)(x + w - 1), y1 = (uint16_t)(y + h - 1);
+
+ /* Opaque, or the panes underneath read through it: a modal 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, MODAL_BG, 0);
+
+ const char *tl = CFG.rounded ? "\u256d" : "\u250c",
+ *tr = CFG.rounded ? "\u256e" : "\u2510";
+ const char *bl = CFG.rounded ? "\u2570" : "\u2514",
+ *br = CFG.rounded ? "\u256f" : "\u2518";
+ screen_text(s, x, y, tl, MODAL_BORDER, MODAL_BG, 0);
+ screen_text(s, x1, y, tr, MODAL_BORDER, MODAL_BG, 0);
+ screen_text(s, x, y1, bl, MODAL_BORDER, MODAL_BG, 0);
+ screen_text(s, x1, y1, br, MODAL_BORDER, MODAL_BG, 0);
+ for (uint16_t xx = (uint16_t)(x + 1); xx < x1; xx++) {
+ screen_text(s, xx, y, "\u2500", MODAL_BORDER, MODAL_BG, 0);
+ screen_text(s, xx, y1, "\u2500", MODAL_BORDER, MODAL_BG, 0);
+ }
+ for (uint16_t yy = (uint16_t)(y + 1); yy < y1; yy++) {
+ screen_text(s, x, yy, "\u2502", MODAL_BORDER, MODAL_BG, 0);
+ screen_text(s, x1, yy, "\u2502", MODAL_BORDER, MODAL_BG, 0);
+ }
+
+ if (title && *title && w > cells(title) + 4) {
+ char buf[80];
+ snprintf(buf, sizeof buf, " %s ", title);
+ screen_text(s, (uint16_t)(x + (w - cells(buf)) / 2), y, buf, MODAL_TITLE,
+ MODAL_BG, ATTR_BOLD);
+ }
+
+ /* The close button a pane has, in the place a pane keeps it. Two cells of
+ * target for a one-cell mark, for the reason the frame's own buttons are
+ * three: a one-cell target is a thing you miss with a mouse. */
+ if (close_action && w > 10) {
+ uint16_t bw = (uint16_t)(cells(CFG.close_mark) + 1);
+ uint16_t bx = (uint16_t)(x1 - bw);
+ bool hot = ptr_on(a, bx, y, bw, 1);
+ char cell[24];
+ snprintf(cell, sizeof cell, "%s ", CFG.close_mark);
+ screen_text(s, bx, y, cell, hot ? MODAL_BTN_HOVER : MODAL_BTN, MODAL_BG,
+ hot ? ATTR_BOLD : 0);
+ hit_add(&s->hits, bx, y, bw, 1, close_action);
+ }
+
+ return (rect_t){(uint16_t)(x + 1), (uint16_t)(y + 1), (uint16_t)(w - 2),
+ (uint16_t)(h - 2)};
+}
+
+/* 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--;
+ }
+ /* Land on the heading itself, not on the blank row above it: a column
+ * that starts with a blank line looks like a mistake. */
+ if (split < n && !rows[split].group && !rows[split].label) 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++) {
+ if (!rows[i].label) continue; /* the blank row wants nothing */
+ 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));
+ size_t left_n = two ? split : n, right_n = two ? n - split : 0;
+ /* The fold falls on a heading, which leaves the blank row above it at the
+ * bottom of the first column: a row that draws nothing and would otherwise
+ * make the box a line taller than it needs to be. */
+ if (left_n && !rows[left_n - 1].label) left_n--;
+ if (right_n && !rows[n - 1].label) right_n--;
+ uint16_t lines = (uint16_t)(left_n > right_n ? left_n : right_n);
+
+ /* A terminal too short for the whole sheet gets as much of it as fits and a line
+ * saying how much it did not. `modal_frame` clamps the box to the screen, so
+ * without this the rows past the bottom are simply not drawn -- and a list that
+ * quietly stops is worse than a short one, because the reader has no way to know
+ * the key they are looking for is on it. */
+ uint16_t room = (uint16_t)(s->rows > 7 ? s->rows - 7 : 1);
+ uint16_t shown = lines > room ? room : lines;
+ /* Counted, not calculated: the two columns are not the same length and a blank
+ * spacer is not a row anybody is missing. Arithmetic on `lines` claimed six rows
+ * were hidden on a screen showing all of them. */
+ size_t hidden = 0;
+ for (size_t i = 0; i < n; i++) {
+ size_t row = (two && i >= split) ? i - split : i;
+ if (row >= shown && rows[i].label) hidden++;
+ }
+
+ rect_t in = modal_frame(a, s, (uint16_t)(body + 6),
+ (uint16_t)(shown + 6 + (hidden ? 1 : 0)), "keys",
+ "closehelp");
+ uint16_t x1 = (uint16_t)(in.x + in.w); /* the right border */
+
+ /* Everything here is prefix-then-key, and saying so once beats repeating
+ * the prefix on thirty rows. */
+ char lead[80], 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)(in.x + 2), (uint16_t)(in.y + 1), lead, in.w, HINT_C,
+ MODAL_BG, 0);
+
+ for (size_t i = 0; i < n; i++) {
+ bool right = two && i >= split;
+ size_t row = right ? i - split : i;
+ if (row >= shown) continue; /* past the bottom: counted, not drawn */
+ uint16_t ry = (uint16_t)(in.y + 3 + row);
+ if (ry >= in.y + in.h) continue;
+ if (!rows[i].label) continue; /* the blank row: it occupies, it draws not */
+ uint16_t rx = (uint16_t)(in.x + 2 + (right ? cw[0] + 4 : 0));
+
+ /* 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 ? x1 - rx : 0);
+ if (two && !right && cw[0] + 2 < room) room = (uint16_t)(cw[0] + 2);
+
+ if (!rows[i].group) {
+ help_text(s, rx, ry, rows[i].label, room, MODAL_TITLE, MODAL_BG,
+ ATTR_BOLD);
+ continue;
+ }
+ help_text(s, rx, ry, rows[i].chord, room, MODAL_TITLE, MODAL_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 ? x1 - lx : 0),
+ MODAL_FG, MODAL_BG, 0);
+ }
+
+ /* Said inside the box, where the rows stopped, rather than left to be noticed. */
+ if (hidden) {
+ char more[64];
+ snprintf(more, sizeof more, "+%zu more, in docs/keys.md", hidden);
+ help_text(s, (uint16_t)(in.x + 2), (uint16_t)(in.y + 3 + shown), more, in.w,
+ HINT_C, MODAL_BG, 0);
+ }
+
+ const char *foot = " any key closes this ";
+ if (in.w > cells(foot))
+ screen_text(s, (uint16_t)(in.x + (in.w - cells(foot)) / 2),
+ (uint16_t)(in.y + in.h), foot, HINT_C, MODAL_BG, 0);
+}
+
+/* How many rows of results the box shows at once. Past this it scrolls, and
+ * the point of a picker is that you narrow rather than scroll. */
+#define FINDER_ROWS 10
+
+/* One row of a picker.
+ *
+ * Three columns, because both pickers turned out to want the same three: a
+ * dim thing on the left that says where it belongs (a tab, a group), the
+ * thing itself in the middle, and a dim tail on the right that says something
+ * useful about it (a purpose, the chord that would run it). */
+typedef struct {
+ char left[24];
+ char mid[80];
+ char right[80];
+ bool here; /* mark this row: the pane you are already in */
+ char action[48]; /* what a click on it does */
+} pick_row_t;
+
+/* A band of background, for a row that is selected or under the pointer. The
+ * whole width, not just the text: a highlight that stops where the words stop
+ * reads as a highlighted *word* rather than a chosen row. */
+static void fill_row(screen_t *s, uint16_t x, uint16_t y, uint16_t w,
+ color_t bg) {
+ for (uint16_t i = 0; i < w; i++)
+ screen_text(s, (uint16_t)(x + i), y, " ", NO_COLOR, bg, 0);
+}
+
+/* Fill `out` with the panes matching the query. */
+static size_t finder_rows(app_t *a, pick_row_t *out, size_t max) {
+ find_entry_t entries[64];
+ size_t n = finder_entries(a, entries, max < 64 ? max : 64);
+ for (size_t i = 0; i < n; i++) {
+ find_entry_t *e = &entries[i];
+ pick_row_t *r = &out[i];
+ memset(r, 0, sizeof *r);
+ /* A tab with no name is its number alone: "1:" with nothing after it
+ * looks like something failed to load. */
+ const char *tname = e->tab == (size_t)-1 ? "" : a->tabs[e->tab].name;
+ if (tname && *tname)
+ snprintf(r->left, sizeof r->left, "%zu:%.10s", e->tab + 1, tname);
+ else
+ snprintf(r->left, sizeof r->left, "%zu", e->tab + 1);
+ const char *title = pane_title(e->node->pane);
+ snprintf(r->mid, sizeof r->mid, "%s", title && *title ? title : "pane");
+ snprintf(r->right, sizeof r->right, "%s", e->node->purpose);
+ r->here = e->node == cur(a)->focus && e->tab == a->cur;
+ snprintf(r->action, sizeof r->action, "find:%u", e->node->id);
+ }
+ return n;
+}
+
+/* ---- the command palette -------------------------------------------------
+ *
+ * Every action, by name, without having to know its key. The cheatsheet
+ * answers "what is the key for this?" and needs you to then press it; this
+ * answers "just do the thing", which is the question you have when you cannot
+ * remember there was a key at all.
+ *
+ * It lists actions that are *not* bound too, which the cheatsheet cannot --
+ * an unbound action is exactly the one you have no other way to reach. Where
+ * a binding does exist it is shown on the row, so using the palette teaches
+ * the key that would have skipped it.
+ */
+static size_t palette_rows(app_t *a, pick_row_t *out, size_t max) {
+ size_t n = 0;
+ for (int act = ACT_NONE + 1; act < ACT_SELECT_TAB_1 && n < max; act++) {
+ const char *label = config_action_label((action_t)act);
+ const char *group = config_action_group((action_t)act);
+ const char *name = config_action_name((action_t)act);
+ if (!label || !group || !name) continue;
+ /* "send the prefix itself" means "type this key", which is not a command
+ * you can ask for by name -- picking it from a list would send the prefix
+ * to a pane you cannot see behind the list. */
+ if (act == ACT_LITERAL_PREFIX) continue;
+
+ /* The chord, if it has one, and the prefix it needs. A direct binding
+ * carries no prefix, so saying so would be a lie about how to press it. */
+ /* Every chord for it, the way the cheatsheet lists them: binding a key
+ * in a config adds to the defaults rather than replacing them, so showing
+ * only the first would name a key the user did not choose and hide the
+ * one they did. A direct binding carries no prefix, and saying otherwise
+ * would be a lie about how to press it. */
+ char chord[48] = "";
+ size_t used = 0;
+ for (size_t i = 0; i < CFG.nbinds && used < sizeof chord - 12; i++) {
+ if (CFG.binds[i].action != (action_t)act) continue;
+ char c[24], pfx[24] = "";
+ config_chord_name(CFG.binds[i].key, CFG.binds[i].mods, c, sizeof c);
+ if (!CFG.binds[i].direct)
+ config_chord_name(CFG.prefix_key, CFG.prefix_mods, pfx, sizeof pfx);
+ int wrote = snprintf(chord + used, sizeof chord - used, "%s%s%s%s",
+ used ? " \u00b7 " : "", pfx, *pfx ? " " : "", c);
+ if (wrote > 0) used += (size_t)wrote;
+ }
+
+ /* Matched on everything the row shows *and* on the name it has in a
+ * config file, so "split-cols" finds it for someone who has been editing
+ * the config and "split into columns" for someone who has not. */
+ char hay[256];
+ snprintf(hay, sizeof hay, "%s %s %s %s", label, group, name, chord);
+ if (!ci_contains(hay, a->query)) continue;
+
+ pick_row_t *r = &out[n++];
+ memset(r, 0, sizeof *r);
+ snprintf(r->left, sizeof r->left, "%s", group);
+ snprintf(r->mid, sizeof r->mid, "%s", label);
+ snprintf(r->right, sizeof r->right, "%s", chord);
+ snprintf(r->action, sizeof r->action, "run:%d", act);
+ }
+ return n;
+}
+
+/* ---- the project picker --------------------------------------------------
+ *
+ * The third subject, and the one that answers a different question from the
+ * other two: the finder searches what *exists* in this session, while this
+ * lists what exists on *disk* -- including the projects with no workspace open
+ * yet, which is most of them and the whole point.
+ *
+ * The scan happens once, when the picker opens, into a->projects. Not per frame:
+ * draw_picker asks for its rows every frame, and a readdir of ~/dev at 120Hz
+ * would be a filesystem walk per repaint. Once per opening is also exactly when
+ * the answer has to be right, so nothing is remembered for longer than that.
+ */
+static size_t workspace_rows(app_t *a, pick_row_t *out, size_t max) {
+ size_t n = 0;
+ for (size_t i = 0; i < a->nprojects && n < max; i++) {
+ const project_t *p = &a->projects[i];
+ uint32_t tab = app_workspace_tab(a, p->slug);
+ /* Matched on the name, the path and what kind it is, so `.git` narrows to
+ * the ones with no layout yet -- which is the list you want when you are
+ * about to write one. */
+ char hay[640];
+ snprintf(hay, sizeof hay, "%s %s %s", p->name, p->path,
+ p->layout[0] ? PROJECT_LAYOUT_FILE : ".git");
+ if (!ci_contains(hay, a->query)) continue;
+
+ pick_row_t *r = &out[n++];
+ memset(r, 0, sizeof *r);
+ /* The root it came from rather than the whole path: which of `~/dev` and
+ * `~/work` a project is in is the part that distinguishes two of the same
+ * name, and the rest is the name again. */
+ const char *slash = strrchr(p->path, '/');
+ size_t keep = slash ? (size_t)(slash - p->path) : strlen(p->path);
+ const char *root_end = p->path + keep;
+ const char *root_start = root_end;
+ while (root_start > p->path && root_start[-1] != '/') root_start--;
+ snprintf(r->left, sizeof r->left, "%.*s", (int)(root_end - root_start),
+ root_start);
+ snprintf(r->mid, sizeof r->mid, "%s", p->name);
+ /* What it is, or what it already is: an open workspace says how many panes
+ * it has, and a project with no layout says so, which reads as the
+ * invitation it is. */
+ if (tab)
+ snprintf(r->right, sizeof r->right, "%s", p->slug);
+ else
+ snprintf(r->right, sizeof r->right, "%s",
+ p->layout[0] ? PROJECT_LAYOUT_FILE : ".git \u00b7 no layout");
+ r->here = tab != 0 && tab == cur(a)->id;
+ snprintf(r->action, sizeof r->action, "open:%zu", i);
+ }
+ return n;
+}
+
+static size_t picker_rows(app_t *a, pick_row_t *out, size_t max) {
+ if (a->picker == PICK_PALETTE) return palette_rows(a, out, max);
+ if (a->picker == PICK_WORKSPACES) return workspace_rows(a, out, max);
+ return finder_rows(a, out, max);
+}
+
+static size_t picker_count(app_t *a) {
+ pick_row_t rows[128];
+ return picker_rows(a, rows, 128);
+}
+
+/* A picker is a modal, like the cheatsheet: scrim behind it, a pane's frame
+ * around it, a title and a close button in the places a pane keeps them. The
+ * finder used to be a bare coloured rectangle painted over whatever pane it
+ * landed on, which read as output from the program underneath rather than as
+ * a window in front of it -- and gave it no title, no way out you could see,
+ * and no edge to tell you where it ended.
+ *
+ * It differs from the cheatsheet in the one way that matters: the cheatsheet
+ * is *read*, so any key dismisses it, while this one is *used*, so it keeps
+ * the keyboard until it is answered. */
+static void draw_picker(app_t *a, screen_t *s) {
+ pick_row_t rows[128];
+ size_t n = picker_rows(a, rows, 128);
+ if (a->sel >= n) a->sel = n ? n - 1 : 0;
+
+ bool palette = a->picker == PICK_PALETTE;
+ bool projects = a->picker == PICK_WORKSPACES;
+ const char *title = palette ? "commands" : projects ? "projects" : "find";
+ const char *close = palette ? "closepalette"
+ : projects ? "closeprojects"
+ : "closefind";
+
+ /* The window of results on screen. Kept around the selection rather than
+ * anchored at the top, so arrowing past the bottom scrolls instead of
+ * moving the selection somewhere you cannot see it. */
+ size_t shown = n > FINDER_ROWS ? FINDER_ROWS : n;
+ size_t first = a->sel >= shown ? a->sel - shown + 1 : 0;
+
+ uint16_t w = 64;
+ if (w > s->cols - 4) w = (uint16_t)(s->cols > 24 ? s->cols - 4 : s->cols);
+ /* border, query, blank, rows (at least one, for "no matches"), border */
+ uint16_t list = (uint16_t)(shown ? shown : 1);
+ uint16_t h = (uint16_t)(list + 5);
+ if (h > s->rows) h = s->rows;
+
+ rect_t in = modal_frame(a, s, w, h, title, close);
+
+ /* The query, in a field that looks like one: a program you type into should
+ * show you where the typing goes even when you have not typed anything. */
+ uint16_t fy = (uint16_t)(in.y + 1);
+ fill_row(s, in.x, fy, in.w, FINDER_BG);
+ uint16_t qx = (uint16_t)(in.x + 1);
+ qx += help_text(s, qx, fy, "\u203a ", in.w, HINT_C, FINDER_BG, 0);
+ qx += help_text(s, qx, fy, a->query, (uint16_t)(in.x + in.w - qx), FINDER_FG,
+ FINDER_BG, ATTR_BOLD);
+ if (qx < in.x + in.w)
+ screen_text(s, qx, fy, "\u2588", FINDER_FG, FINDER_BG, 0);
+
+ uint16_t ly = (uint16_t)(in.y + 3); /* the first result row */
+
+ if (!n) {
+ /* An empty box says the picker is broken; this says the query is. */
+ const char *none =
+ palette ? "no command matches that"
+ : (a->query[0] ? "no pane matches that" : "no panes");
+ help_text(s, (uint16_t)(in.x + 2), ly, none, (uint16_t)(in.w - 2), HINT_C,
+ MODAL_BG, 0);
+ return;
+ }
+
+ for (size_t i = 0; i < shown; i++) {
+ size_t idx = first + i;
+ if (idx >= n) break;
+ pick_row_t *e = &rows[idx];
+ uint16_t yy = (uint16_t)(ly + i);
+ if (yy >= in.y + in.h) break;
+
+ hit_add(&s->hits, in.x, yy, in.w, 1, e->action);
+
+ /* Selected wins over hovered: the pointer may be resting anywhere, and
+ * the row Enter would take is the one that has to be unambiguous. */
+ bool on = idx == a->sel;
+ bool hot = !on && ptr_on(a, in.x, yy, in.w, 1);
+ color_t bg = on ? FINDER_SEL_BG : (hot ? FINDER_BG : MODAL_BG);
+ color_t fg = on ? FINDER_SEL_FG : FINDER_FG;
+ fill_row(s, in.x, yy, in.w, bg);
+
+ /* Which pane you are in now, so the list says where you are as well as
+ * where you could go. A different mark from the query prompt: two rows
+ * that both begin with the same glyph invite you to read one as the
+ * other. */
+ uint16_t cx = (uint16_t)(in.x + 1);
+ cx += help_text(s, cx, yy, e->here ? "\u2022" : " ", 1, fg, bg, 0);
+ cx++;
+
+ /* Three columns, drawn as three pieces rather than one printf, so the
+ * part that matters can be told from the parts that qualify it. */
+ uint16_t lw = 13;
+ if (in.w > 40) {
+ help_text(s, cx, yy, e->left, lw, on ? fg : HINT_C, bg, 0);
+ cx = (uint16_t)(cx + lw);
+ }
+
+ uint16_t room = (uint16_t)(in.x + in.w > cx ? in.x + in.w - cx - 1 : 0);
+ uint16_t mid_w = (uint16_t)(room > 20 ? (in.w > 56 ? 26 : 18) : room);
+ uint16_t drew = help_text(s, cx, yy, e->mid, mid_w, fg, bg, ATTR_BOLD);
+ cx = (uint16_t)(cx + (drew > mid_w ? drew : mid_w) + 1);
+
+ if (*e->right && cx < in.x + in.w)
+ help_text(s, cx, yy, e->right, (uint16_t)(in.x + in.w - cx - 1),
+ on ? fg : HINT_C, bg, 0);
+ }
+
+ /* What is off the top and bottom of the window. Without this a list that
+ * scrolls looks like a list that ends. */
+ if (first)
+ screen_text(s, (uint16_t)(in.x + in.w - 1), ly, "\u2191", HINT_C, MODAL_BG,
+ 0);
+ if (first + shown < n)
+ screen_text(s, (uint16_t)(in.x + in.w - 1), (uint16_t)(ly + shown - 1),
+ "\u2193", HINT_C, MODAL_BG, 0);
+
+ /* The count belongs where the eye already goes for "how much is there", and
+ * it is the answer to "is my query too narrow, or is there nothing?". */
+ char foot[64];
+ snprintf(foot, sizeof foot, " %zu of %zu \u00b7 \u2191\u2193 enter ",
+ a->sel + 1, n);
+ if (in.w > cells(foot))
+ screen_text(s, (uint16_t)(in.x + (in.w - cells(foot)) / 2),
+ (uint16_t)(in.y + in.h), foot, HINT_C, MODAL_BG, 0);
+}
+
+/* Defined with the other action handling, further down: a picker row that
+ * says "run:" means one of these, and running it is that code's business. */
+/* Push the focused pane one tab along, and say where it went.
+ *
+ * You do not follow it -- which is the right default for a key you might press
+ * twice, and useless without a word about where the thing landed. The name is read
+ * *before* the move and the index *after*: emptying this tab removes it and shifts
+ * everything after, so the number you would have printed first is not the number
+ * anybody will see. */
+bool push_pane_a_tab(app_t *a, bool forward) {
+ if (a->ntabs < 2) {
+ app_toast(a, "only one tab to move it to");
+ return true;
+ }
+ size_t to = (a->cur + (forward ? 1 : a->ntabs - 1)) % a->ntabs;
+ uint32_t tid = a->tabs[to].id;
+ char name[64];
+ snprintf(name, sizeof name, "%s", a->tabs[to].name);
+
+ if (!app_move_pane_to_tab(a, 0, tid, false)) {
+ app_toast(a, "cannot move it there");
+ return true;
+ }
+ size_t at = 0;
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].id == tid) at = i;
+ char msg[128];
+ if (name[0])
+ snprintf(msg, sizeof msg, "moved to tab %zu (%s)", at + 1, name);
+ else
+ snprintf(msg, sizeof msg, "moved to tab %zu", at + 1);
+ app_toast(a, msg);
+ return true;
+}
+
+/* A pane dropped on the tab strip. The keys say this with a word and a drag says it
+ * with a gesture; both end in the same two calls.
+ *
+ * The toast matters more here than for a key: the pane leaves the tab you are
+ * looking at and lands somewhere you are not, so without a word the screen simply
+ * shows one fewer pane. */
+void drop_pane_on_strip(app_t *a) {
+ uint32_t src = a->drag.src;
+ if (a->drag.new_tab_target) {
+ app_toast(a, app_move_pane_to_new_tab(a, src, "")
+ ? "into a tab of its own"
+ : "it is the only pane in this tab");
+ return;
+ }
+ uint32_t tid = a->drag.tab_target;
+ char name[64] = {0};
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].id == tid)
+ snprintf(name, sizeof name, "%s", a->tabs[i].name);
+
+ if (!app_move_pane_to_tab(a, src, tid, false)) {
+ /* The one refusal a drag can reach: the tab it is already in. */
+ app_toast(a, "it is already in that tab");
+ return;
+ }
+ size_t at = 0;
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].id == tid) at = i;
+ char msg[128];
+ if (name[0])
+ snprintf(msg, sizeof msg, "moved to tab %zu (%s)", at + 1, name);
+ else
+ snprintf(msg, sizeof msg, "moved to tab %zu", at + 1);
+ app_toast(a, msg);
+}
+
+bool run_action(app_t *a, action_t act);
+
+/* Do what a row says it does. One entry point for the keyboard and the mouse,
+ * so a picker cannot choose one way with Enter and another with a click. */
+void picker_accept(app_t *a, const char *action) {
+ if (strncmp(action, "find:", 5) == 0)
+ app_focus_pane(a, (uint32_t)strtoul(action + 5, NULL, 10));
+ else if (strncmp(action, "run:", 4) == 0)
+ run_action(a, (action_t)strtol(action + 4, NULL, 10));
+ else if (strncmp(action, "open:", 5) == 0) {
+ /* By index into the snapshot the picker is showing, so the row that was
+ * clicked is the project that opens even if the disk has changed since --
+ * a scan between the paint and the click would otherwise open its
+ * neighbour. */
+ size_t i = (size_t)strtoul(action + 5, NULL, 10);
+ if (i >= a->nprojects) return;
+ app_workspace_open_t w;
+ char err[256] = {0};
+ if (app_workspace_open(a, a->projects[i].path, false, &w, err, sizeof err))
+ app_toast(a, w.created ? "opened" : "already open");
+ else
+ app_toast(a, err[0] ? err : "cannot open that");
+ }
+}
+
+/* Move the selection by `d`, wrapping.
+ *
+ * Wrapping because the list is short and the selection starts at the top: one
+ * press of Up to reach the last entry is worth more here than the protection
+ * against overshooting that a long list wants. */
+static void picker_move(app_t *a, int d) {
+ size_t n = picker_count(a);
+ if (!n) {
+ a->sel = 0;
+ return;
+ }
+ int64_t sel = (int64_t)a->sel + d;
+ int64_t last = (int64_t)n - 1;
+ if (sel < 0) sel = d == -1 ? last : 0; /* a page up stops at the top */
+ if (sel > last) sel = d == 1 ? 0 : last;
+ a->sel = (size_t)sel;
+}
+
+/* Returns true when the picker consumed the event.
+ *
+ * It consumes *everything* while it is open, including keys it does nothing
+ * with: it is a text field, and a text field that lets an unrecognised key
+ * fall through to the pane behind it types into that pane. */
+bool picker_key(app_t *a, const input_event_t *ev) {
+ if (!a->picker) return false;
+ if (ev->kind != EV_KEY || ev->action == KEY_RELEASE) return true;
+
+ bool ctrl = (ev->mods & MOD_CTRL) != 0;
+ bool shift = (ev->mods & MOD_SHIFT) != 0;
+
+ /* The emacs pair every picker in this shape answers to, and which the hands
+ * of anyone who has used one reach for before the arrows. */
+ if (ctrl && (ev->key == GHOSTTY_KEY_N || ev->unshifted == 'n')) {
+ picker_move(a, 1);
+ return true;
+ }
+ if (ctrl && (ev->key == GHOSTTY_KEY_P || ev->unshifted == 'p')) {
+ picker_move(a, -1);
+ return true;
+ }
+ /* Clear the query rather than the whole picker: the shell's own C-u, and
+ * the alternative is backspacing a long wrong guess out one key at a time. */
+ if (ctrl && (ev->key == GHOSTTY_KEY_U || ev->unshifted == 'u')) {
+ a->query[0] = 0;
+ a->sel = 0;
+ return true;
+ }
+
+ switch (ev->key) {
+ case GHOSTTY_KEY_ESCAPE: a->picker = PICK_NONE; return true;
+ case GHOSTTY_KEY_ENTER: {
+ pick_row_t rows[128];
+ size_t n = picker_rows(a, rows, 128);
+ /* The row's own action, the one a click on it would run: a picker that
+ * chose by keyboard down one path and by mouse down another would be
+ * two pickers wearing one box. */
+ char action[48] = "";
+ if (a->sel < n) snprintf(action, sizeof action, "%s", rows[a->sel].action);
+ a->picker = PICK_NONE;
+ if (*action) picker_accept(a, action);
+ return true;
+ }
+ case GHOSTTY_KEY_TAB: picker_move(a, shift ? -1 : 1); return true;
+ case GHOSTTY_KEY_ARROW_DOWN: picker_move(a, 1); return true;
+ case GHOSTTY_KEY_ARROW_UP: picker_move(a, -1); return true;
+ case GHOSTTY_KEY_PAGE_DOWN: picker_move(a, FINDER_ROWS); return true;
+ case GHOSTTY_KEY_PAGE_UP: picker_move(a, -FINDER_ROWS); return true;
+ case GHOSTTY_KEY_HOME: a->sel = 0; return true;
+ case GHOSTTY_KEY_END: {
+ size_t n = picker_count(a);
+ a->sel = n ? n - 1 : 0;
+ return true;
+ }
+ case GHOSTTY_KEY_BACKSPACE: {
+ /* A character, not a byte: the query can hold whatever a pane's title
+ * did, and half a UTF-8 sequence is not a search. (The rename editor
+ * learned this first; this one was still eating bytes.) */
+ size_t l = strlen(a->query);
+ while (l && ((unsigned char)a->query[l - 1] & 0xC0) == 0x80) l--;
+ if (l) l--;
+ a->query[l] = 0;
+ a->sel = 0;
+ return true;
+ }
+ default: break;
+ }
+ if (!ctrl && ev->text_len && (unsigned char)ev->text[0] >= 0x20) {
+ size_t l = strlen(a->query);
+ if (l + ev->text_len < sizeof a->query) {
+ memcpy(a->query + l, ev->text, ev->text_len);
+ a->query[l + ev->text_len] = 0;
+ a->sel = 0;
+ }
+ }
+ return true;
+}
+
+/* ---- renaming a pane or a tab ------------------------------------------- */
+
+/* The editor lives in the label itself — a pane's title cell (draw_frame) or a
+ * tab's cell in the strip (draw_tab_strip) — so the name is typed where it is
+ * going to live. There is no dialog to place, nothing to dismiss, and what is
+ * being renamed cannot be in doubt. */
+void rename_begin(app_t *a, uint32_t id) {
+ node_t *n = pane_by_id(a, id);
+ if (!n) return;
+ a->renaming = RENAME_PANE;
+ a->rename_id = id;
+ /* Seed with what is on screen: a rename is usually an edit, not a retype. */
+ const char *shown = pane_title(n->pane);
+ snprintf(a->rename_buf, sizeof a->rename_buf, "%s", shown ? shown : "");
+ a->name_click_ms = 0;
+ a->name_click_id = 0;
+ app_focus_pane(a, id);
+}
+
+/* The same editor, on the pane's other label. A purpose is what tooling finds a
+ * pane by (D8), and until now it could only be declared by a layout or set over
+ * the socket -- so every pane anybody arranged by hand had none, and a layout
+ * dumped from one was a shape with no tags in it. Typed here it counts as
+ * *declared*, because D8's `declared` means "from a layout or an operator" and
+ * a person at the keyboard is the operator. */
+void purpose_begin(app_t *a, uint32_t id) {
+ node_t *n = pane_by_id(a, id);
+ if (!n) return;
+ a->renaming = RENAME_PURPOSE;
+ a->rename_id = id;
+ /* Seeded, unlike a tab's name: here the purpose *is* the thing being edited,
+ * and the usual edit is adding `:2` to the end of one. */
+ snprintf(a->rename_buf, sizeof a->rename_buf, "%s", n->purpose);
+ a->name_click_ms = 0;
+ a->name_click_id = 0;
+ app_focus_pane(a, id);
+}
+
+void rename_tab_begin(app_t *a, uint32_t id) {
+ tab_t *t = tab_by_id(a, id);
+ if (!t) return;
+ a->renaming = RENAME_TAB;
+ a->rename_id = id;
+ /* Seeded with the name only. A pane seeds with its title because a title is
+ * always something; a tab shows its *number* until it is named, and a number
+ * is not a name you are editing — offering "3" to backspace over would be
+ * offering to edit the wrong thing. A purpose is not seeded either: it comes
+ * from a layout and outranks what is typed here. */
+ snprintf(a->rename_buf, sizeof a->rename_buf, "%s", t->name);
+ a->name_click_ms = 0;
+ a->name_click_id = 0;
+ app_select_tab_id(a, id);
+}
+
+void rename_end(app_t *a, bool keep) {
+ if (!a->renaming) return;
+ if (keep) {
+ bool done = false;
+ const char *what = "name";
+ if (a->renaming == RENAME_PURPOSE) {
+ what = "purpose";
+ done = app_set_pane_purpose(a, a->rename_id, a->rename_buf, true);
+ } else if (a->renaming == RENAME_PANE) {
+ done = app_set_pane_name(a, a->rename_id, a->rename_buf);
+ } else {
+ done = app_set_tab_name(a, a->rename_id, a->rename_buf);
+ }
+ if (done) {
+ char said[40];
+ snprintf(said, sizeof said, "%s %s", what,
+ a->rename_buf[0] ? "set" : "cleared");
+ /* "renamed" for a name, because that is the word for it; "purpose set"
+ * for the other, because "renamed" would be a lie about which label
+ * moved. */
+ app_toast(a, a->renaming == RENAME_PURPOSE
+ ? said
+ : (a->rename_buf[0] ? "renamed" : "name cleared"));
+ }
+ }
+ a->renaming = RENAME_NONE;
+ a->rename_id = 0;
+ a->rename_buf[0] = 0;
+}
+
+/* Returns true when the rename editor consumed the event. */
+bool rename_key(app_t *a, const input_event_t *ev) {
+ if (!a->renaming) return false;
+ if (ev->kind != EV_KEY || ev->action == KEY_RELEASE) return true;
+
+ switch (ev->key) {
+ case GHOSTTY_KEY_ESCAPE: rename_end(a, false); return true;
+ case GHOSTTY_KEY_ENTER: rename_end(a, true); return true;
+ case GHOSTTY_KEY_BACKSPACE: {
+ /* A character, not a byte: a title is whatever the program could set,
+ * and half a UTF-8 sequence is not a name. */
+ size_t l = strlen(a->rename_buf);
+ while (l && ((unsigned char)a->rename_buf[l - 1] & 0xC0) == 0x80) l--;
+ if (l) l--;
+ a->rename_buf[l] = 0;
+ return true;
+ }
+ default: break;
+ }
+ if (ev->text_len && (unsigned char)ev->text[0] >= 0x20) {
+ size_t l = strlen(a->rename_buf);
+ if (l + ev->text_len < sizeof a->rename_buf) {
+ memcpy(a->rename_buf + l, ev->text, ev->text_len);
+ a->rename_buf[l + ev->text_len] = 0;
+ }
+ }
+ return true;
+}
+
+void app_compose(app_t *a, screen_t *s) {
+ screen_clear(s); /* every frame starts blank: no ghosts in the gap ring */
+ hit_reset(&s->hits);
+ s->cursor_visible = false;
+ a->painted = s;
+ /* Re-derived by the shader passes below, every frame, from what they ran. */
+ a->animating = false;
+ if (!a->ntabs || !cur(a)->root) return;
+
+ /* Looking at a pane is the acknowledgement, and it happens before anything
+ * is drawn rather than while the pane itself is: the tab strip is painted
+ * first and would otherwise spend a frame reporting a bell that had just
+ * been answered. Done here so that every route to "this pane is focused"
+ * clears it, not only the ones that thought to. */
+ if (cur(a)->focus) pane_clear_bell(cur(a)->focus->pane);
+
+ layout(a);
+ find_corners(a);
+ if (CFG.status_bar) draw_tab_strip(a, s);
+ draw_node(a, s, cur(a)->root);
+ draw_corners(a, s);
+ draw_min_bar(a, s);
+ draw_status_line(a, s);
+ /* The modals, on top of everything: each is the only thing that can be
+ * interacted with while it is up, so it owns the topmost hits. The scrim
+ * goes down first, over everything already painted, and once for both —
+ * dimming twice would make the second one darker for no reason. */
+ if (a->picker || a->help) draw_scrim(a, s);
+ if (a->picker) draw_picker(a, s);
+ if (a->help) draw_help(a, s);
+ draw_toasts(a, s); /* and above even that: it is transient */
+}