diff options
| author | user <user@clank> | 2026-08-19 13:47:10 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-19 13:47:10 +0200 |
| commit | ffaf9ff9db77573612317882b96158b5c7ba9655 (patch) | |
| tree | b8371eaa03be35bd7ba23e70c7ae8421c0387440 /src/app.c | |
| parent | slosh: defaults most people can live in, said by shaders (diff) | |
| download | slosh-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.c')
| -rw-r--r-- | src/app.c | 5370 |
1 files changed, 33 insertions, 5337 deletions
@@ -1,4 +1,8 @@ -/* The layout tree, focus, chrome, and what keys do. See app.h. */ +/* The session core: config in force, pane and tab lifecycle, tree walking, + * input routing, and introspection. See app.h for the public surface, and + * app_internal.h for what the split shares -- layout and tree edits live in + * app_layout.c, layout files and workspaces in app_session.c, drawing and + * shading in app_draw.c, pickers and modals in app_ui.c. */ #define _GNU_SOURCE #include "app.h" @@ -24,7 +28,7 @@ /* One config per process. The server owns the session, and a session has one * look; `reload` re-reads it in place, which is why nothing caches a colour. */ -static config_t CFG; +config_t CFG; static bool CFG_LOADED = false; /* What the config in force complained about while loading, or "". A complaint * is not a failure: an include that is not there, a shader nobody has heard of, @@ -34,7 +38,7 @@ static bool CFG_LOADED = false; * loud. The front end toasts it; the log gets it either way. */ static char CFG_COMPLAINT[256]; -static void ensure_config(void) { +void ensure_config(void) { if (CFG_LOADED) return; config_defaults(&CFG); char err[256] = {0}; @@ -84,317 +88,8 @@ size_t app_config_files(const char **out, size_t max) { return config_files(&CFG, out, max); } -#define WEIGHT_UNIT 1000 -#define WEIGHT_MIN 150 /* a pane can be squeezed, not squeezed out */ -#define WEIGHT_STEP 120 /* one keyboard nudge */ - -#define MIN_PANE_COLS (CFG.min_pane_cols) -#define MIN_PANE_ROWS (CFG.min_pane_rows) -#define FRAME_FOCUS (CFG.frame_focus) -#define FRAME_IDLE (CFG.frame_idle) -#define TITLE_FOCUS (CFG.title_focus) -#define BTN_FG (CFG.button_fg) -#define BTN_BG (CFG.button_bg) -#define BTN_BG_IDLE (CFG.button_bg_idle) -#define TITLE_IDLE (CFG.title_idle) -#define GUIDE (CFG.guide) -#define RESIZE_C (CFG.resize) -#define DROP_C (CFG.drop_target) -#define SCROLL_FG (CFG.scroll_fg) -#define SCROLL_BG (CFG.scroll_bg) -#define HEADER (CFG.header) -#define HEADER_HOVER (CFG.header_hover) -#define HEADER_HOVER_TITLE (CFG.header_hover_title) -#define TAB_ACTIVE_FG (CFG.tab_active_fg) -#define TAB_ACTIVE_BG (CFG.tab_active_bg) -#define TAB_ACTIVE_HOVER_FG (CFG.tab_active_hover_fg) -#define TAB_IDLE (CFG.tab_idle) -#define TAB_HOVER (CFG.tab_hover) -#define PREFIX_FG (CFG.prefix_fg) -#define PREFIX_BG (CFG.prefix_bg) -#define TAB_COUNT (CFG.tab_count) -#define STATUS_C (CFG.status) -#define STATUS_STATE (CFG.status_state) -#define FINDER_FG (CFG.finder_fg) -#define FINDER_BG (CFG.finder_bg) -#define FINDER_SEL_FG (CFG.finder_sel_fg) -#define FINDER_SEL_BG (CFG.finder_sel_bg) -#define TOAST_FG (CFG.toast_fg) -#define TOAST_BG (CFG.toast_bg) -#define RENAME_FG (CFG.rename_fg) -#define RENAME_BG (CFG.rename_bg) -#define BELL_C (CFG.bell) -#define PANE_BTN (CFG.pane_button) -#define PANE_BTN_HOVER (CFG.pane_button_hover) -#define MINBAR (CFG.minbar) -#define MINBAR_HOVER (CFG.minbar_hover) -#define HINT_C (CFG.hint) -#define DEAD_C (CFG.dead) -#define MODAL_FG (CFG.modal_fg) -#define MODAL_BG (CFG.modal_bg) -#define MODAL_BORDER (CFG.modal_border) -#define MODAL_TITLE (CFG.modal_title) -#define MODAL_BTN (CFG.modal_button) -#define MODAL_BTN_HOVER (CFG.modal_button_hover) - -static const color_t NO_COLOR = {0}; - /* ---- tree --------------------------------------------------------------- */ - -/* A chain attached to one pane, with the programs its expressions compiled to - * kept beside it. Together because they are one lifetime: freeing the chain - * without the programs leaks, and freeing the programs without the chain leaves - * shaders pointing at freed code. */ -typedef struct { - shader_t sh[SHADE_MAX]; - size_t n; - expr_prog_t *exprs[SHADE_MAX]; - size_t nexprs; -} inband_chain_t; - -struct node { - enum { NODE_LEAF, NODE_SPLIT } kind; - node_t *parent; - rect_t rect; /* recomputed every layout pass; never trusted between them */ - - /* Share of the parent split, in arbitrary units. Even splits are simply - * equal weights, so resizing is not a special case of anything. */ - int weight; - - /* Put away: out of the layout, into a strip along the bottom, still running. - * Intent, like the weight beside it and unlike the rect and the collapsed - * flag below — layout_node must not clear this, or it would un-minimise - * every pane on every frame. */ - bool minimized; - - /* leaf */ - pane_t *pane; - uint32_t id; - rect_t content; /* where the pane's cells go */ - - /* Colour passes over this pane, applied in order: one chain for its contents - * and one for its frame, because those are two rects and not one list with a - * sort key. Sent in-band by the program inside the pane, which `shaders.md` - * calls prototyping and D13 called a hazard -- both are true, so it takes - * `in_band_shaders true` to be possible at all. - * - * Each chain owns the programs its expressions compiled to. The config owns - * the ones a file produced and frees them on reload; these live exactly as - * long as the chain that was sent, which is until the next one replaces it or - * the pane goes away, and that is nobody else's schedule. */ - inband_chain_t content_chain, chrome_chain; - char purpose[64]; - bool purpose_locked; /* declared by a layout: in-band cannot override */ - - /* When this pane last became what it is, for `since` (D20). A *transition* - * cannot be derived from the frame in front of you — the frame only says - * what is true now — so this is remembered, and it is remembered in the one - * place that decides the state, which is what keeps the timestamp and the - * state it belongs to from disagreeing. `last_state` is the state it was in - * when `state_since` was stamped; PSTATE_COUNT is "in none of them", which - * is a state to have been in for a while like any other. */ - pane_state_t last_state; - int64_t state_since; - - /* split */ - split_dir_t dir; - node_t **kids; - size_t nkids; - - /* recomputed every layout pass, like the rect: this subtree did not fit and - * is drawn as a one-row header (D6). Never remembered between passes. */ - bool collapsed; - bool hidden; /* a leaf inside a collapsed subtree: running, not drawn */ -}; - -/* A tab is a layout tree and its focus. Panes in every tab keep running; only - * the current tab is composed. */ -/* Where a row boundary and a column boundary meet, and the boundaries that - * meet there. Recomputed every frame with the layout it describes. */ -typedef struct { - rect_t r; - uint32_t h_id[2]; /* row boundaries meeting here: these move vertically */ - size_t h_edge[2]; - size_t nh; - uint32_t v_id[2]; /* column boundaries: these move horizontally */ - size_t v_edge[2]; - size_t nv; -} corner_t; - -typedef struct { - node_t *root; - node_t *focus; - /* Where the minimised panes are listed, or zero-sized when none are. Set by - * the layout and consumed by the drawing, rather than each working the - * geometry out for itself. */ - rect_t min_bar; - /* The pane filling this tab on its own, or 0. Intent rather than derived - * state, like focus: nothing about the tree says a pane is zoomed, you said - * so. Kept per tab so zooming one does not disturb another. */ - uint32_t zoom; - uint32_t id; - char name[64]; - char purpose[64]; - bool purpose_locked; -} tab_t; - -struct app { - tab_t *tabs; - size_t ntabs, tabcap, cur; - uint32_t next_tab_id; - uint32_t next_id; - uint16_t cols, rows; - /* The client's cell size in pixels, as reported when it attached. Panes are - * told so that a program can size an image, and so lib-vt can work out how - * many cells an image covers when the program did not say. Defaulted rather - * than zeroed: 0 means "no image can be placed", which is not a good thing - * for a headless session or a terminal that will not say. */ - uint16_t cell_w, cell_h; - bool prefix; - bool quit; - bool detach; - /* One drag machine, two verbs: a title drags a pane onto another to swap - * them, a gap between panes drags the boundary. Both are started by a press - * on something the hit list says is draggable, so neither can disagree with - * what is on screen. */ - struct { - enum { - DRAG_NONE, - DRAG_TITLE, - DRAG_EDGE, - DRAG_SELECT, - DRAG_BORDER, - DRAG_TAB, - } kind; - uint32_t src; /* pane being dragged, or the split being resized */ - uint32_t target; /* pane under the pointer, for the drop highlight */ - /* ...or the tab under it, when a pane is dragged over the strip: the same - * gesture with a different kind of destination. Never both -- the pointer is - * over one thing, and two highlights would be two promises. */ - uint32_t tab_target; - bool new_tab_target; /* the strip's `+`: a tab of its own */ - size_t edge; /* which boundary of that split */ - uint16_t x, y; /* where the pointer was at the last event */ - bool moved; /* a press that never moves is a click */ - char side; /* border press: 'l' 'r' 't' 'b' */ - /* A corner drag carries the boundaries it is moving, not an index into - * the corner list: that list is rebuilt from the layout every frame, and - * the layout is the thing being changed. Halfway through a drag the - * crossing you grabbed may be a different entry, or gone — an index would - * quietly start moving somebody else's panes. */ - uint32_t c_h[2]; - size_t c_hedge[2]; - size_t c_nh; - uint32_t c_v[2]; - size_t c_vedge[2]; - size_t c_nv; - } drag; - - graphics_t *gfx; /* kitty images, and what the client has been told */ - - /* Where the pointer is. Which border that *means* is derived during the - * paint, from the rects painted in that same pass — remembering the answer - * instead left the guide describing the layout from before a split, until - * the mouse moved and corrected it. */ - uint16_t ptr_x, ptr_y; - bool ptr_valid; - int64_t ptr_still_since; /* when the pointer last stopped moving */ - - /* Transient announcements: "copied 13 chars", a notification from a pane, - * a config reload. They stack upward from the bottom right and expire on - * their own, so nothing has to be dismissed. */ - struct { - char text[128]; - int64_t until; - } toasts[3]; - size_t ntoasts; - - /* What a selection put on the clipboard, kept for middle-click paste (the - * X11 primary-selection habit) and handed to the client as OSC 52. */ - 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 modal picker: a query, a filtered list, one selection. Two things - * wear it -- the pane finder (tabs stop being navigation past about six) - * and the command palette -- and they are one machine with a subject - * rather than two that would drift apart, for the same reason the rename - * editor is. Only one can be open, which is what makes sharing the query - * and the selection honest rather than a saving. */ - enum { PICK_NONE = 0, PICK_FINDER, PICK_PALETTE, PICK_WORKSPACES } picker; - /* What the project picker is listing: scanned once when it opens, because - * draw_picker asks for its rows every frame and a readdir per repaint is a - * filesystem walk at 120Hz. Once per opening is when the answer has to be - * right, which is why nothing is kept for longer. */ - project_t *projects; - size_t nprojects; - char query[64]; - size_t sel; - - /* Renaming in place: the title cell becomes the editor, so the name is typed - * where it will live rather than in a dialog somewhere else. A pane's title - * and a tab's label are the same gesture on two different things, so this is - * one editor with a subject rather than two machines that would drift. */ - enum { RENAME_NONE = 0, RENAME_PANE, RENAME_TAB, RENAME_PURPOSE } renaming; - uint32_t rename_id; - /* As wide as a pane title, so seeding the editor never truncates one (and so - * never truncates one mid-UTF-8). */ - char rename_buf[256]; - /* The first click of a candidate double-click on a title. The *kind* is - * remembered with it: pane ids and tab ids are separate sequences, so pane 2 - * and tab 2 both exist, and without this a click on one followed by a click - * on the other reads as a double-click on neither. */ - int64_t name_click_ms; - uint32_t name_click_id; - - /* The same idea for a pane's contents: a second click on the *same cell* of - * the same pane selects the word there. Per cell as well as per pane, so - * clicking two different words quickly selects the second one rather than - * everything between them. */ - int64_t cell_click_ms; - uint32_t cell_click_id; - uint16_t cell_click_x, cell_click_y; - int name_click_kind; - /* Set while a layout is being built, by a `focus=true` pane, and consumed - * by the tab that contains it: focus belongs to a tab, and the tab does not - * exist yet when the pane declaring it is made. */ - node_t *restore_focus; - /* Which tab a layout said was active, resolved after every tab exists -- - * the index is only meaningful once the array has stopped growing. */ - size_t restore_tab; - - /* Set for the duration of one app_apply_layout: every pane it builds starts - * suspended whatever the file said. `open-workspace suspended:true` is the - * "open ten projects, run zero processes" case, which is a different question - * from the one a project's layout answered about which of *its* panes are - * expensive -- and it has to be decided before anything spawns, because a pane - * is created suspended or created running and there is no un-running one. */ - bool force_suspend; - const char *const *argv; - /* What this session is called. The server knows it because it opened the - * socket under that name; the app only knows it because it is worth saying - * out loud when several are running. */ - char session[64]; - /* The last layout pass made this tab a list rather than a layout (D6). Same - * rule as `animating`: derived every pass, never remembered across one. */ - bool flattened; - /* the screen we last composed into: its hit list is what a click resolves - * against, so routing can never consult geometry the user never saw */ - corner_t corners[16]; - size_t ncorners; - const screen_t *painted; - /* Something on the frame we last composed is animated: a shader whose amount - * reads the clock ran over it. Derived every frame from the chains actually - * applied, like the rect and the collapsed flag and for the same reason — - * remembering "this session animates" would keep a clock running for a pulse - * that was hung off a state no pane is in any more. */ - bool animating; -}; - -static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; } +#include "app_internal.h" /* Is the pointer sitting on this rect right now? * @@ -407,10 +102,9 @@ static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; } * row: booking one column for a glyph drawn in two moves everything after it, * and every length in this file -- title budgets, button positions, the hit * rects that must agree with them -- is computed from here. */ -static uint16_t cells(const char *str) { return screen_cells(str); } +uint16_t cells(const char *str) { return screen_cells(str); } -static bool ptr_on(const app_t *a, uint16_t x, uint16_t y, uint16_t w, - uint16_t h) { +bool ptr_on(const app_t *a, uint16_t x, uint16_t y, uint16_t w, uint16_t h) { return a->ptr_valid && a->drag.kind == DRAG_NONE && a->ptr_x >= x && a->ptr_x < x + w && a->ptr_y >= y && a->ptr_y < y + h; } @@ -418,7 +112,7 @@ static bool ptr_on(const app_t *a, uint16_t x, uint16_t y, uint16_t w, /* "exited", "exited: status 3", "exited: signal 9" — the same words wherever * the fact is reported, so the status line, the pane's own frame and the line * left in its backlog cannot describe one death three ways. */ -static void exit_words(const pane_t *p, char *out, size_t cap) { +void exit_words(const pane_t *p, char *out, size_t cap) { int code = 0; bool sig = false; if (!pane_exit(p, &code, &sig) || (!sig && code == 0)) @@ -428,7 +122,7 @@ static void exit_words(const pane_t *p, char *out, size_t cap) { } typedef void (*leaf_fn_fwd)(node_t *, void *); -static void walk_all(app_t *a, leaf_fn_fwd fn, void *ud); +void walk_all(app_t *a, leaf_fn_fwd fn, void *ud); struct bypane { const pane_t *pane; @@ -535,7 +229,7 @@ static void on_pane_osc(pane_t *p, const char *verb, const char *payload, if (b.found) app_set_pane_purpose(a, b.found->id, payload, false); } -static int64_t now_ms_(void) { +int64_t now_ms_(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; @@ -609,9 +303,9 @@ size_t app_toast_count(app_t *a) { return a->ntoasts; } -static size_t count_leaves(node_t *n); /* all defined with the layout */ -static size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k); -static size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k); +size_t count_leaves(node_t *n); /* all defined with the layout */ +size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k); +size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k); /* What the thing under the pointer would do, in a word. * @@ -672,7 +366,7 @@ static const char *hint_for(app_t *a, const char *action) { * are the ones you can otherwise only discover by being surprised. */ /* The minimised panes, listed along one row. Shaped like the tab strip because * it is the same kind of thing: a row of names you click to go somewhere. */ -static void draw_min_bar(app_t *a, screen_t *s) { +void draw_min_bar(app_t *a, screen_t *s) { if (!a->ntabs) return; rect_t bar = cur(a)->min_bar; if (!bar.w || !bar.h) return; @@ -719,7 +413,7 @@ static void draw_min_bar(app_t *a, screen_t *s) { } } -static void draw_status_line(app_t *a, screen_t *s) { +void draw_status_line(app_t *a, screen_t *s) { if (!CFG.status_line || s->rows < 3) return; uint16_t y = (uint16_t)(s->rows - CFG.gap - 1); uint16_t x = CFG.status_pad; @@ -858,7 +552,7 @@ static void draw_status_line(app_t *a, screen_t *s) { screen_text(s, (uint16_t)from, y, middle, middle_fg, NO_COLOR, 0); } -static void draw_toasts(app_t *a, screen_t *s) { +void draw_toasts(app_t *a, screen_t *s) { toasts_expire(a); if (!a->ntoasts) return; uint16_t bottom = (uint16_t)(s->rows > 1 ? s->rows - 1 : 0); @@ -920,8 +614,8 @@ char *app_take_clipboard(app_t *a) { const char *app_clipboard(const app_t *a) { return a->clipboard; } -static node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd, - bool suspended, const char *label) { +node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd, + bool suspended, const char *label) { pane_t *p = pane_new_ex(argv, 1, 1, cwd, suspended, label); if (!p) return NULL; node_t *n = calloc(1, sizeof *n); @@ -947,7 +641,7 @@ static node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd, * saving affects the next pane you open instead of the next session you * start. The array is static because pane_new copies what it is given before * this could be called again. */ -static const char *const *default_argv(app_t *a) { +const char *const *default_argv(app_t *a) { static const char *argv[2]; if (a->argv && a->argv[0]) return a->argv; const char *sh = CFG.shell && *CFG.shell ? CFG.shell : getenv("SHELL"); @@ -959,24 +653,24 @@ static const char *const *default_argv(app_t *a) { /* Where the program in a pane *is*, which after any amount of `cd` is not where * it was started. Defined with the dumper, declared here because a split wants * it too: a new pane opens where the one it came out of stands. */ -static const char *live_cwd(const pane_t *p, char *buf, size_t cap); +const char *live_cwd(const pane_t *p, char *buf, size_t cap); /* A plain pane running the session's shell. One line, because everything a pane * needs setting up is in leaf_new_ex and a second copy of that list is a second * place to forget something -- which is exactly how panes came to have every * handler attached and no history limit. */ -static node_t *leaf_new(app_t *a) { +node_t *leaf_new(app_t *a) { return leaf_new_ex(a, default_argv(a), NULL, false, ""); } /* Empty a chain, freeing the programs it owned. */ -static void chain_clear(inband_chain_t *c) { +void chain_clear(inband_chain_t *c) { for (size_t i = 0; i < c->nexprs; i++) expr_free(c->exprs[i]); c->nexprs = 0; c->n = 0; } -static void node_free(node_t *n) { +void node_free(node_t *n) { if (!n) return; if (n->kind == NODE_LEAF) { chain_clear(&n->content_chain); @@ -991,7 +685,7 @@ static void node_free(node_t *n) { /* Purposes are how tooling finds "the agent pane" (D8). Sanitised on ingest to * the charset sl0ppi settled on, so the format survives verbatim. */ -static void sanitise_purpose(const char *in, char *out, size_t cap) { +void sanitise_purpose(const char *in, char *out, size_t cap) { size_t n = 0; for (const char *p = in; *p && n + 1 < cap; p++) { char c = *p; @@ -1003,7 +697,7 @@ static void sanitise_purpose(const char *in, char *out, size_t cap) { out[n] = 0; } -static tab_t *tab_add(app_t *a, const char *name) { +tab_t *tab_add(app_t *a, const char *name) { if (a->ntabs == a->tabcap) { a->tabcap = a->tabcap ? a->tabcap * 2 : 4; a->tabs = realloc(a->tabs, a->tabcap * sizeof *a->tabs); @@ -1059,7 +753,7 @@ void app_clear_detach(app_t *a) { a->detach = false; } typedef void (*leaf_fn)(node_t *, void *); -static void walk(node_t *n, leaf_fn fn, void *ud) { +void walk(node_t *n, leaf_fn fn, void *ud) { if (!n) return; if (n->kind == NODE_LEAF) { fn(n, ud); @@ -1068,23 +762,19 @@ static void walk(node_t *n, leaf_fn fn, void *ud) { for (size_t i = 0; i < n->nkids; i++) walk(n->kids[i], fn, ud); } -static void walk_all(app_t *a, leaf_fn fn, void *ud) { +void walk_all(app_t *a, leaf_fn fn, void *ud) { for (size_t i = 0; i < a->ntabs; i++) walk(a->tabs[i].root, fn, ud); } /* Which tab a node lives in: climb to its root and match. */ -static size_t tab_of(app_t *a, node_t *n) { +size_t tab_of(app_t *a, node_t *n) { while (n->parent) n = n->parent; for (size_t i = 0; i < a->ntabs; i++) if (a->tabs[i].root == n) return i; return (size_t)-1; } -struct byid { - uint32_t id; - node_t *found; -}; -static void byid_cb(node_t *n, void *ud) { +void byid_cb(node_t *n, void *ud) { struct byid *b = ud; if (n->id == b->id) b->found = n; } @@ -1157,4797 +847,6 @@ bool app_pump_fd(app_t *a, int fd) { return true; } -/* ---- layout: a pure function of the tree and the rect ------------------- */ - -/* Every leaf under a node, in tree order. */ -static size_t collect_leaves(node_t *n, node_t **out, size_t cap, size_t k) { - if (!n || k >= cap) return k; - if (n->kind == NODE_LEAF) { - out[k++] = n; - return k; - } - for (size_t i = 0; i < n->nkids; i++) - k = collect_leaves(n->kids[i], out, cap, k); - return k; -} - -/* Does anything under here still want space in the layout? */ -static bool subtree_live(node_t *n) { - if (!n) return false; - if (n->kind == NODE_LEAF) return !n->minimized; - for (size_t i = 0; i < n->nkids; i++) - if (subtree_live(n->kids[i])) return true; - return false; -} - -static size_t collect_minimized(node_t *n, node_t **out, size_t cap, size_t k) { - if (!n || k >= cap) return k; - if (n->kind == NODE_LEAF) { - if (n->minimized) out[k++] = n; - return k; - } - for (size_t i = 0; i < n->nkids; i++) - k = collect_minimized(n->kids[i], out, cap, k); - return k; -} - -static size_t count_leaves(node_t *n) { - if (!n) return 0; - if (n->kind == NODE_LEAF) return 1; - size_t k = 0; - for (size_t i = 0; i < n->nkids; i++) k += count_leaves(n->kids[i]); - return k; -} - -/* A collapsed pane keeps running and keeps its size — it is simply not drawn, - * and deliberately not resized, so nothing inside it reflows to one row and - * back while you drag a terminal narrower. */ -static void collapse_leaf(node_t *leaf, rect_t r) { - leaf->collapsed = true; - leaf->hidden = true; - leaf->rect = r; - /* `content` is deliberately left alone. It is the size the program inside - * still believes it has — it was not resized on the way in and must not - * appear to have been — and nothing draws a collapsed pane's content, so a - * header rect there would only be a more convincing lie. */ -} - -/* The splits above a flattened stack span the whole area and draw nothing. - * Their children no longer sit side by side, so the gap loop in draw_node - * finds no gap between any two of them and registers no resize handles — - * which is right, because there is nothing left to resize. */ -static void span_splits(node_t *n, rect_t r) { - if (!n || n->kind == NODE_LEAF) return; - n->rect = r; - n->collapsed = false; - for (size_t i = 0; i < n->nkids; i++) span_splits(n->kids[i], r); -} - -static node_t *first_leaf_of(node_t *n) { - while (n && n->kind == NODE_SPLIT) n = n->kids[0]; - return n; -} - -/* Laying out happens twice: once to ask whether the tab fits at all, and once - * to do it. The probe must not resize any pane, because a pane it lays out - * normally may be a header by the time the real pass is done, and a pane that - * is about to be collapsed must not be told a size it will never show. */ -typedef struct { - node_t *focus; - bool apply; /* false during the probe */ - bool overflow; /* some node could not give its children their floor */ -} layout_ctx_t; - -static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx); - -/* The collapse: the hierarchy stops existing for as long as it does not fit. - * - * Every pane under this node becomes one row in a flat list, with the focused - * one opened below them. Nesting is exactly what there is no room to express, - * so a subtree that cannot fit does not get to keep spending rows saying how - * its panes are arranged — and a reader in this state wants "which pane", - * not "which arrangement". - * - * Flat also makes every pane reachable. Collapsing a *subtree* to one header - * gave every leaf inside it the same rect and one hit for the first of them, - * so the rest could not be clicked at all. One row each fixes that by - * construction. - * - * Headers first and the body below, rather than the open pane sitting in its - * own position: switching then moves only the content, and the list you are - * picking from holds still. The tree order it would have preserved is the - * thing being flattened away anyway. - * - * Recomputed from the rect every frame, so there is no state to go stale — - * which is the whole argument for D6 over swap layouts. */ -static void layout_stack(node_t *n, rect_t r, layout_ctx_t *ctx) { - node_t *leaves[64]; - size_t nl = collect_leaves(n, leaves, 64, 0); - if (!nl) return; - - size_t expanded = 0; - for (size_t i = 0; i < nl; i++) - if (ctx->focus && leaves[i] == ctx->focus) expanded = i; - - span_splits(n, r); - - uint16_t headers = (uint16_t)(nl - 1); - uint16_t body = r.h > headers ? (uint16_t)(r.h - headers) : 1; - - uint16_t y = r.y; - for (size_t i = 0; i < nl; i++) { - if (i == expanded) continue; - collapse_leaf(leaves[i], (rect_t){r.x, y, r.w, 1}); - y = (uint16_t)(y + 1); - } - layout_node(leaves[expanded], (rect_t){r.x, y, r.w, body}, ctx); -} - -/* Not even room for one row per pane plus something to look at: show the - * focused pane and nothing else. The alternative is rects that do not fit on - * the screen, and a pane one cell tall helps no one. */ -static void layout_solo(node_t *n, rect_t r, layout_ctx_t *ctx) { - node_t *leaves[64]; - size_t nl = collect_leaves(n, leaves, 64, 0); - if (!nl) return; - - size_t expanded = 0; - for (size_t i = 0; i < nl; i++) - if (ctx->focus && leaves[i] == ctx->focus) expanded = i; - - span_splits(n, r); - for (size_t i = 0; i < nl; i++) - if (i != expanded) collapse_leaf(leaves[i], (rect_t){r.x, r.y, 0, 0}); - layout_node(leaves[expanded], r, ctx); -} - -static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { - /* Minimised panes are placed by the strip, not by the tree. Returning before - * touching the rect leaves the strip's placement standing, and returning - * before the floor check is right too: a pane that is one row by request is - * not a pane that failed to fit. */ - if (n->kind == NODE_LEAF && n->minimized) return; - - n->rect = r; - n->collapsed = false; - if (n->kind == NODE_LEAF) n->hidden = false; - if (n->kind == NODE_LEAF) { - /* content is the frame deflated by its border and its padding, per side; a - * rect too small for a frame gets neither, and the pane takes the whole - * thing. Horizontal padding is aspect-corrected, like the gap: the config's - * numbers are rows, so a square-looking ring is one number rather than an - * arithmetic problem. */ - bool framed = r.w >= 3 && r.h >= 3; - uint16_t left = framed ? 1 + CFG.pad_left * CFG.gap_aspect : 0; - uint16_t right = framed ? 1 + CFG.pad_right * CFG.gap_aspect : 0; - uint16_t top = framed ? 1 + CFG.pad_top : 0; - uint16_t bottom = framed ? 1 + CFG.pad_bottom : 0; - n->content = (rect_t){ - .x = (uint16_t)(r.x + left), - .y = (uint16_t)(r.y + top), - .w = (uint16_t)(r.w > left + right ? r.w - left - right : 1), - .h = (uint16_t)(r.h > top + bottom ? r.h - top - bottom : 1), - }; - if (ctx->apply && !n->hidden) - pane_resize(n->pane, n->content.w, n->content.h); - - /* A pane has to clear the floor in *both* directions, whatever arrangement - * produced it. The check on a split node only ever asks about the - * dimension that node divides — a row split asks about height and never - * about width — so a column of stacked panes could be squeezed to four - * cells wide with nothing in the tree responsible for noticing. The leaf - * is the only place that can see both of its own sides. */ - if (r.w < MIN_PANE_COLS || r.h < MIN_PANE_ROWS) ctx->overflow = true; - return; - } - - /* Divide among the children that still want space. A split with one live - * child is that child, exactly as a split with one child would be. */ - node_t *live[64]; - size_t k = 0; - for (size_t i = 0; i < n->nkids && k < 64; i++) - if (subtree_live(n->kids[i])) live[k++] = n->kids[i]; - if (k == 0) return; - if (k == 1) { - layout_node(live[0], r, ctx); - return; - } - - uint16_t gap = - n->dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) : CFG.gap; - uint16_t total = n->dir == SPLIT_COLS ? r.w : r.h; - - /* Does every child clear the floor? If not, this node collapses — a local - * decision, made from this rect, affecting nothing above or below it. */ - uint16_t floor_ = n->dir == SPLIT_COLS ? MIN_PANE_COLS : MIN_PANE_ROWS; - uint16_t need = (uint16_t)(k * floor_ + gap * (k - 1)); - if (total < need) { - /* Report and stop. Whether this becomes a stack is not this node's call - * any more: a tab is either laid out or it is a list, and half a screen of - * each is the state nobody wants to be looking at. layout() decides, once, - * for the whole tab. */ - ctx->overflow = true; - return; - } - - uint16_t gaps = (uint16_t)(gap * (k - 1)); - uint16_t avail = total > gaps ? (uint16_t)(total - gaps) : (uint16_t)k; - if (avail / k == 0) { /* below one cell each, nothing sensible is left */ - ctx->overflow = true; - return; - } - - /* Sizes are proportional to weights; the remainder goes to the widest - * children, largest first, so nothing drifts and nothing rounds to zero. */ - long total_weight = 0; - for (size_t i = 0; i < k; i++) total_weight += live[i]->weight; - if (total_weight <= 0) total_weight = 1; - - uint16_t sizes[64]; - uint16_t used = 0; - for (size_t i = 0; i < k && i < 64; i++) { - long want = (long)avail * live[i]->weight / total_weight; - if (want < 1) want = 1; - sizes[i] = (uint16_t)want; - used = (uint16_t)(used + sizes[i]); - } - for (size_t i = 0; used < avail && i < k; i = (i + 1) % k) { - sizes[i]++; - used++; - } - for (size_t i = 0; used > avail && i < k; i = (i + 1) % k) { - if (sizes[i] > 1) { - sizes[i]--; - used--; - } - } - - uint16_t pos = n->dir == SPLIT_COLS ? r.x : r.y; - for (size_t i = 0; i < k; i++) { - uint16_t size = sizes[i]; - rect_t cr = n->dir == SPLIT_COLS - ? (rect_t){.x = pos, .y = r.y, .w = size, .h = r.h} - : (rect_t){.x = r.x, .y = pos, .w = r.w, .h = size}; - layout_node(live[i], cr, ctx); - pos = (uint16_t)(pos + size + gap); - } -} - -#define STRIP_ROWS (CFG.status_bar ? 1 : 0) -#define LINE_ROWS (CFG.status_line ? 1 : 0) - -static node_t *pane_by_id(app_t *a, uint32_t id); /* defined below */ -static size_t tab_of(app_t *a, node_t *n); - -static void layout(app_t *a) { - if (!a->ntabs) return; - /* Re-derived below, like everything else about a layout pass: whether this - * tab is a layout or a list (D6). Recorded because a tree edit needs to be - * able to ask what its edit did — a turn that flattens the tab is a turn - * nobody asked for. */ - a->flattened = false; - uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap; - uint16_t top = (uint16_t)(gy + STRIP_ROWS); - rect_t r = {.x = gx, - .y = top, - .w = (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols), - .h = (uint16_t)(a->rows > top + gy + LINE_ROWS - ? a->rows - top - gy - LINE_ROWS - : 1)}; - node_t *root = cur(a)->root; - if (!root) return; - - /* Focusing a minimised pane is how you get it back, and it is checked here - * rather than in the several places focus can move: any route that ends with - * this pane focused restores it, including ones that do not exist yet. */ - if (cur(a)->focus && cur(a)->focus->minimized) - cur(a)->focus->minimized = false; - - /* A zoomed pane is the whole tab. Reuses the solo path rather than adding a - * third arrangement: "show this one and nothing else" is a thing the layout - * already knew how to do, it just used to be a last resort rather than a - * request. The id is validated here so a zoom cannot outlive its pane. */ - if (cur(a)->zoom) { - node_t *z = pane_by_id(a, cur(a)->zoom); - if (!z || tab_of(a, z) != a->cur) { - cur(a)->zoom = 0; - } else { - layout_ctx_t only = {.focus = z, .apply = true}; - layout_solo(root, r, &only); - return; - } - } - - /* Ask first, then do it. A tab is either laid out or it is a list of panes; - * there is no third state where one half of the screen is a stack and the - * other half is not, because that is the arrangement that explains neither - * what happened nor what to do about it. - * - * So the question is asked of the whole tab: if *any* node cannot give its - * children their floor, the entire tab flattens. The probe is the real - * layout with resizing switched off, rather than a second implementation of - * the same arithmetic — two copies of "does this fit" is exactly how the - * guide and the layout would start disagreeing. */ - /* Minimised panes come out of the layout and sit in a strip along the - * bottom, one row each. The tree is then laid out in what is left, so a - * minimised pane costs a row rather than a share. */ - /* Minimised panes are listed on one row along the bottom, however many there - * are. A row each would let putting things away cost more room than having - * them out, which is the opposite of the point. */ - node_t *mins[64]; - size_t nmin = collect_minimized(root, mins, 64, 0); - rect_t tree_r = r; - bool no_room = false; - cur(a)->min_bar = (rect_t){0, 0, 0, 0}; - if (nmin) { - if (r.h >= (uint16_t)(MIN_PANE_ROWS + 3)) { - tree_r.h = (uint16_t)(r.h - 1); - cur(a)->min_bar = (rect_t){r.x, (uint16_t)(r.y + r.h - 1), r.w, 1}; - } else { - no_room = true; - } - } - - layout_ctx_t probe = {.focus = cur(a)->focus, .apply = false}; - layout_node(root, tree_r, &probe); - /* Nowhere to put the bar means nowhere to put anything: flatten, where every - * pane is a row and being minimised is not a different thing to be. */ - if (no_room) probe.overflow = true; - - layout_ctx_t real = {.focus = cur(a)->focus, .apply = true}; - if (!probe.overflow) { - layout_node(root, tree_r, &real); - /* They are drawn by the bar, not as panes: no rect of their own. */ - for (size_t i = 0; i < nmin; i++) - collapse_leaf(mins[i], (rect_t){r.x, r.y, 0, 0}); - return; - } - a->flattened = true; - cur(a)->min_bar = (rect_t){0, 0, 0, 0}; /* a list has no separate bar */ - if (r.h >= (uint16_t)(count_leaves(root) + 2)) { - /* One row per pane, plus a body worth opening into: a frame is two rows. */ - layout_stack(root, r, &real); - } else { - layout_solo(root, r, &real); - } -} - -void app_write_focused(app_t *a, const void *buf, size_t len) { - if (cur(a)->focus) pane_write(cur(a)->focus->pane, buf, len); -} - -void app_resize(app_t *a, uint16_t cols, uint16_t rows) { - a->cols = cols; - a->rows = rows; - layout(a); -} - -static void set_cell_px_cb(node_t *n, void *ud) { - const uint16_t *px = ud; - pane_set_cell_px(n->pane, px[0], px[1]); -} - -void app_set_cell_px(app_t *a, uint16_t w, uint16_t h) { - if (!w || !h || (w == a->cell_w && h == a->cell_h)) return; - a->cell_w = w; - a->cell_h = h; - uint16_t px[2] = {w, h}; - /* Every pane, not just the visible ones: a background tab's program may be - * drawing images too, and it should not have to be looked at to find out - * how big a cell is. */ - walk_all(a, set_cell_px_cb, px); -} - -void app_cell_px(const app_t *a, uint16_t *w, uint16_t *h) { - if (w) *w = a->cell_w; - if (h) *h = a->cell_h; -} - -/* ---- tree edits --------------------------------------------------------- */ - -static void replace_child(node_t *parent, node_t *old, node_t *new_) { - for (size_t i = 0; i < parent->nkids; i++) - if (parent->kids[i] == old) { - parent->kids[i] = new_; - new_->parent = parent; - return; - } -} - -/* The pane a split makes. NULL argv means "whatever this session runs", which - * is the ordinary case; a command makes it a task pane instead. */ -static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, - bool before, const char *const *argv, - const char *label); - -static void split_node(app_t *a, node_t *leaf, split_dir_t dir, bool before) { - split_node_with(a, leaf, dir, before, NULL, NULL); -} - -/* Put `node` beside `leaf`, splitting `dir`, on the side `before` names. - * - * The tree surgery of a split without the pane: `split_node_with` hands it a leaf - * it just made, and moving a pane between tabs hands it one that already exists - * somewhere else. `t` is the tab `leaf` lives in, because replacing a root has to - * land on the right one -- `cur(a)` was right only while the only caller was - * splitting the pane you were looking at. */ -static void place_beside(app_t *a, tab_t *t, node_t *leaf, node_t *node, - split_dir_t dir, bool before) { - /* Growing an existing split in the same direction keeps the tree flat, so - * three vertical splits are three equal columns rather than 1/2 + 1/4 + 1/4. */ - if (leaf->parent && leaf->parent->dir == dir) { - node_t *p = leaf->parent; - p->kids = realloc(p->kids, (p->nkids + 1) * sizeof *p->kids); - size_t at = 0; - while (at < p->nkids && p->kids[at] != leaf) at++; - size_t slot = before ? at : at + 1; - memmove(&p->kids[slot + 1], &p->kids[slot], - (p->nkids - slot) * sizeof *p->kids); - p->kids[slot] = node; - node->parent = p; - p->nkids++; - } else { - node_t *sp = calloc(1, sizeof *sp); - sp->kind = NODE_SPLIT; - sp->id = ++a->next_id; - sp->weight = leaf->weight; /* the new split inherits the pane's share */ - leaf->weight = WEIGHT_UNIT; - node->weight = WEIGHT_UNIT; - sp->dir = dir; - sp->nkids = 2; - sp->kids = malloc(2 * sizeof *sp->kids); - sp->kids[before ? 1 : 0] = leaf; - sp->kids[before ? 0 : 1] = node; - sp->parent = leaf->parent; - if (leaf->parent) - replace_child(leaf->parent, leaf, sp); - else - t->root = sp; - leaf->parent = sp; - node->parent = sp; - } -} - -static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, - bool before, const char *const *argv, - const char *label) { - if (!leaf) return NULL; - /* A new pane opens where the pane it came out of *is*, not where the session - * was started. Splitting inside a project and landing in whatever directory - * the server happened to be launched from is wrong on its own, and it made a - * saved project layout carry an absolute path to somewhere else entirely. */ - char cwdbuf[4096]; - const char *cwd = live_cwd(leaf->pane, cwdbuf, sizeof cwdbuf); - node_t *fresh = argv ? leaf_new_ex(a, argv, cwd, false, label) - : leaf_new_ex(a, default_argv(a), cwd, false, ""); - if (!fresh) return NULL; - - place_beside(a, cur(a), leaf, fresh, dir, before); - cur(a)->focus = fresh; - layout(a); - return fresh; -} - -/* Would splitting this pane produce two panes worth having? - * - * This is the layout's own arithmetic, not an approximation of it: the same - * floor, the same gap, and the same child count the split would actually - * create — which is one more sibling when the parent already splits this way, - * and two when it does not. Guessing here would let the guide promise a split - * that immediately collapsed, and the guide's whole claim is that drawing and - * the layout cannot disagree. */ -static bool split_fits(node_t *leaf, split_dir_t dir) { - /* Two different questions, and the larger answer wins: min_pane is where the - * layout gives up, min_split is where splitting stopped being worth - * offering. A pane can clear the first and still be too small to be worth - * halving — which is the whole point of having both. */ - uint16_t floor_ = dir == SPLIT_COLS ? CFG.min_split_cols : CFG.min_split_rows; - uint16_t hard = dir == SPLIT_COLS ? MIN_PANE_COLS : MIN_PANE_ROWS; - if (floor_ < hard) floor_ = hard; - uint16_t gap = - dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) : CFG.gap; - - size_t k; - uint16_t total; - if (leaf->parent && leaf->parent->dir == dir) { - k = leaf->parent->nkids + 1; - total = dir == SPLIT_COLS ? leaf->parent->rect.w : leaf->parent->rect.h; - } else { - k = 2; - total = dir == SPLIT_COLS ? leaf->rect.w : leaf->rect.h; - } - if (total < (uint16_t)(k * floor_ + gap * (k - 1))) return false; - - /* And the axis this split does *not* divide has to already clear the floor, - * because splitting will not improve it. A pane four rows tall on a short - * terminal has room for two columns by every measure this function used to take, - * and produces two panes the layout immediately collapses into a list -- so the - * border was offering, and the keys were doing, something that could only undo - * the arrangement. The same hole `layout_node` closed at the leaf: the check on a - * split only ever asked about the dimension that split divides. */ - uint16_t across = dir == SPLIT_COLS ? leaf->rect.h : leaf->rect.w; - uint16_t across_floor = dir == SPLIT_COLS ? MIN_PANE_ROWS : MIN_PANE_COLS; - return across >= across_floor; -} - -static split_dir_t side_dir(char side) { - return (side == 'l' || side == 'r') ? SPLIT_COLS : SPLIT_ROWS; -} - -static void split_focus(app_t *a, split_dir_t dir) { - split_node(a, cur(a)->focus, dir, false); -} - -/* Splitting because a person just asked for it, which is the only case the - * floor applies to. - * - * The keyboard obeys the same floor the border does: a rule only the mouse - * followed would mean the same session splits or refuses depending on how you - * asked, and whether there is room cannot depend on that. The control API and - * layout files go through split_focus() and are deliberately *not* bound by - * it — a script asking for a pane is declaring what it wants, not being - * offered something, and D6's responsive collapse is what catches it there. */ -static void split_focus_ui(app_t *a, split_dir_t dir) { - node_t *n = cur(a)->focus; - if (!n) return; - if (!split_fits(n, dir)) { - app_toast(a, dir == SPLIT_COLS ? "no room to split across" - : "no room to split down"); - return; - } - split_node(a, n, dir, false); -} - -/* Which way this pane wants to be cut: across its longer side, so the two halves - * come out as square as the pane allows. - * - * "Longer" is a question about what you can *see*, and a cell is about twice as - * tall as it is wide -- so 80x24 is wider than it is tall (80 against 48) while - * 40x24 is not (40 against 48). `gap_aspect` is already this program's answer to - * how many columns a row is worth, so it is the number used here rather than a - * second constant meaning the same thing: a config that says cells are square gets - * a square answer. */ -static split_dir_t preferred_dir(const node_t *leaf) { - uint16_t aspect = CFG.gap_aspect ? CFG.gap_aspect : 1; - return (uint32_t)leaf->rect.w >= (uint32_t)leaf->rect.h * aspect ? SPLIT_COLS - : SPLIT_ROWS; -} - -/* Split whichever way there is room for, preferring the longer side. - * - * The fallback is the point: a pane that is wide but six rows tall wants cutting - * across, and if that will not fit `split_fits` says so and the other axis is tried - * before giving up. Refusing while a split was available would be the worse answer. - * It does not pick quietly either -- one key that can do two things has to say - * which it did. */ -static void split_focus_auto(app_t *a) { - node_t *n = cur(a)->focus; - if (!n) return; - split_dir_t want = preferred_dir(n); - split_dir_t other = want == SPLIT_COLS ? SPLIT_ROWS : SPLIT_COLS; - split_dir_t dir; - if (split_fits(n, want)) - dir = want; - else if (split_fits(n, other)) - dir = other; - else { - app_toast(a, "no room to split"); - return; - } - split_node(a, n, dir, false); - /* The same two words the border click uses for the same two outcomes. */ - app_toast(a, dir == SPLIT_COLS ? "split right" : "split down"); -} - -static node_t *first_leaf(node_t *n) { - while (n && n->kind == NODE_SPLIT) n = n->kids[0]; - return n; -} - -static void tab_remove(app_t *a, size_t ti) { - node_free(a->tabs[ti].root); - memmove(&a->tabs[ti], &a->tabs[ti + 1], - (a->ntabs - ti - 1) * sizeof *a->tabs); - a->ntabs--; - if (a->cur >= a->ntabs && a->ntabs) a->cur = a->ntabs - 1; -} - -static void close_leaf(app_t *a, node_t *leaf) { - size_t ti = tab_of(a, leaf); - if (ti == (size_t)-1) return; - tab_t *t = &a->tabs[ti]; - node_t *p = leaf->parent; - - if (!p) { /* the tab's last pane: the tab goes with it */ - t->root = NULL; - node_free(leaf); - tab_remove(a, ti); - if (a->ntabs == 0) a->quit = true; - return; - } - - size_t at = 0; - while (at < p->nkids && p->kids[at] != leaf) at++; - memmove(&p->kids[at], &p->kids[at + 1], - (p->nkids - at - 1) * sizeof *p->kids); - p->nkids--; - node_free(leaf); - - node_t *survivor = p->kids[0]; - if (p->nkids == 1) { /* a split with one child is just that child */ - survivor->parent = p->parent; - if (p->parent) - replace_child(p->parent, p, survivor); - else - t->root = survivor; - free(p->kids); - free(p); - } - - if (t->focus == leaf || t->focus == p) t->focus = first_leaf(survivor); - layout(a); -} - -struct reap { - app_t *a; - node_t *dead; -}; -/* Does this pane's corpse stay? The label is the command it was given, and is - * empty for a pane that is just a shell -- which is the whole distinction, and - * it happens to already be recorded. */ -static bool keep_corpse(const pane_t *p) { - /* A pane opened to do one thing is done when that thing ends, whatever the - * policy says about commands: you asked for an editor, not for a record of - * having had one. */ - if (pane_ephemeral(p)) return false; - switch (CFG.keep_dead) { - case KEEP_DEAD_ALL: return true; - case KEEP_DEAD_NONE: return false; - default: return pane_label(p)[0] != 0; - } -} - -static void reap_cb(node_t *n, void *ud) { - struct reap *r = ud; - if (!r->dead && !pane_alive(n->pane) && !keep_corpse(n->pane)) r->dead = n; -} - -/* Closing a pane whose program exited is a *policy*, not bookkeeping. - * - * A pane that was given a command keeps its corpse: the error is worth - * reading and [re-run] is worth having. A pane that is a shell goes when you - * type `exit`, because that is what typing `exit` means. `keep_dead` moves - * the line either way. */ -void app_reap(app_t *a) { - ensure_config(); - for (;;) { - struct reap r = {a, NULL}; - walk_all(a, reap_cb, &r); - if (!r.dead) break; - close_leaf(a, r.dead); - if (a->ntabs == 0) break; - } -} - -uint32_t app_new_tab(app_t *a, const char *name) { - tab_t *t = tab_add(a, name); - node_t *leaf = leaf_new(a); - if (!leaf) { - a->ntabs--; - return 0; - } - t->root = leaf; - t->focus = leaf; - a->cur = a->ntabs - 1; - layout(a); - return t->id; -} - -bool app_select_tab(app_t *a, size_t index) { - if (index >= a->ntabs) return false; - a->cur = index; - layout(a); - return true; -} - -bool app_select_tab_id(app_t *a, uint32_t id) { - for (size_t i = 0; i < a->ntabs; i++) - if (a->tabs[i].id == id) return app_select_tab(a, i); - return false; -} - -void app_cycle_tab(app_t *a, int delta) { - if (a->ntabs < 2) return; - long n = (long)a->ntabs; - a->cur = (size_t)(((long)a->cur + delta % n + n) % n); - layout(a); -} - -size_t app_tab_count(const app_t *a) { return a->ntabs; } - -bool app_close_tab(app_t *a, uint32_t id) { - for (size_t i = 0; i < a->ntabs; i++) { - if (a->tabs[i].id != id) continue; - tab_remove(a, i); - if (a->ntabs == 0) - a->quit = true; - else - layout(a); - return true; - } - return false; -} - -/* D8's trust model: a purpose declared by a layout outranks an in-band one and - * cannot be overridden, so `cat hostile.txt` in a pane cannot relabel a - * project tab. `declared` is only ever true on the control path. - * - * Clearing a declared purpose *unlocks* it. Setting it to nothing means "this - * pane has no purpose of mine", and a lock held over an empty string would be - * the one state nobody can get out of -- the program can no longer label - * itself and there is nothing there to have outranked it. Same shape as - * clearing a pane's name, which hands the title back to the program. */ -static void set_purpose(char *slot, size_t cap, bool *locked, - const char *purpose, bool declared) { - sanitise_purpose(purpose, slot, cap); - if (declared) *locked = slot[0] != 0; -} - -/* `id` 0 is the focused pane, and the tab you are looking at, the way it is - * everywhere else a verb takes one (`close`, `rerun`, `clear-shaders`). It was - * the one addressable thing that refused it, which read as "that purpose is - * not allowed" rather than "say which pane". */ -bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose, - bool declared) { - node_t *found = NULL; - if (!id) { - found = cur(a)->focus; - } else { - struct byid b = {id, NULL}; - walk_all(a, byid_cb, &b); - found = b.found; - } - if (!found) return false; - if (found->purpose_locked && !declared) return false; - set_purpose(found->purpose, sizeof found->purpose, &found->purpose_locked, - purpose, declared); - return true; -} - -bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose, - bool declared) { - tab_t *t = id ? NULL : cur(a); - for (size_t i = 0; !t && i < a->ntabs; i++) - if (a->tabs[i].id == id) t = &a->tabs[i]; - if (!t) return false; - if (t->purpose_locked && !declared) return false; - set_purpose(t->purpose, sizeof t->purpose, &t->purpose_locked, purpose, - declared); - return true; -} - -static tab_t *tab_by_id(app_t *a, uint32_t id) { - for (size_t i = 0; i < a->ntabs; i++) - if (a->tabs[i].id == id) return &a->tabs[i]; - return NULL; -} - -static size_t tab_index(app_t *a, uint32_t id) { - for (size_t i = 0; i < a->ntabs; i++) - if (a->tabs[i].id == id) return i; - return (size_t)-1; -} - -/* Move a tab to another position in the strip. - * - * A list operation on a list: the strip *is* the order, so there is no - * separate ordering to keep in sync with it. `cur` is a position rather than - * an identity, so it has to be recomputed here — carrying it across a move - * would leave you looking at whichever tab slid into the index you used to - * be at, which is the kind of bug that only shows up in front of someone. */ -static void move_tab(app_t *a, size_t from, size_t to) { - if (from == to || from >= a->ntabs || to >= a->ntabs) return; - tab_t moved = a->tabs[from]; - if (from < to) - memmove(&a->tabs[from], &a->tabs[from + 1], (to - from) * sizeof *a->tabs); - else - memmove(&a->tabs[to + 1], &a->tabs[to], (from - to) * sizeof *a->tabs); - a->tabs[to] = moved; - - if (a->cur == from) - a->cur = to; - else if (a->cur > from && a->cur <= to) - a->cur--; - else if (a->cur >= to && a->cur < from) - a->cur++; -} - -bool app_move_tab(app_t *a, uint32_t id, size_t index) { - size_t from = tab_index(a, id); - if (from == (size_t)-1 || index >= a->ntabs) return false; - move_tab(a, from, index); - return true; -} - -bool app_set_tab_name(app_t *a, uint32_t id, const char *name) { - for (size_t i = 0; i < a->ntabs; i++) - if (a->tabs[i].id == id) { - snprintf(a->tabs[i].name, sizeof a->tabs[i].name, "%s", name); - return true; - } - return false; -} - -/* Control-API verbs that address a pane by id, rather than "the focused one". - * They select the pane's tab first, so scripting a background tab works. */ -static node_t *pane_by_id(app_t *a, uint32_t id) { - struct byid b = {id, NULL}; - walk_all(a, byid_cb, &b); - return b.found; -} - -/* Naming a pane, for the double-click gesture and for a script: 0 is the focused - * one, and an empty name clears it. - * - * This is the whole answer to a program that keeps setting a title it stopped - * meaning -- an agent still spinning the summary of a task it finished, say. - * `pane_title` prefers a name only while there is one, so what a person or a - * script wrote outranks the program's own until they hand it back. Naming does - * not select the pane's tab, unlike the verbs that rearrange things: a label is - * not a reason to move what somebody is looking at. */ -bool app_set_pane_name(app_t *a, uint32_t id, const char *name) { - node_t *n = id ? pane_by_id(a, id) : cur(a)->focus; - if (!n) return false; - pane_set_name(n->pane, name); - return true; -} - -bool app_minimize(app_t *a, uint32_t id) { - node_t *n = id ? pane_by_id(a, id) : cur(a)->focus; - if (!n || n->minimized) return false; - size_t ti = tab_of(a, n); - if (ti == (size_t)-1) return false; - - /* Refused if it would leave nothing on screen. A tab showing no panes at all - * has no way back that is discoverable from the tab, and "everything is put - * away" is not a state worth being able to reach by accident. */ - node_t *next = NULL; - node_t *leaves[64]; - size_t nl = collect_leaves(a->tabs[ti].root, leaves, 64, 0); - for (size_t i = 0; i < nl; i++) - if (leaves[i] != n && !leaves[i]->minimized) { - next = leaves[i]; - break; - } - if (!next) return false; - - n->minimized = true; - a->cur = ti; - /* Focus has to leave, or the rule that a focused pane is never minimised - * would undo this on the next frame. */ - if (a->tabs[ti].focus == n) a->tabs[ti].focus = next; - layout(a); - return true; -} - -bool app_toggle_zoom(app_t *a, uint32_t id) { - node_t *n = id ? pane_by_id(a, id) : cur(a)->focus; - if (!n) return false; - size_t ti = tab_of(a, n); - if (ti == (size_t)-1) return false; - a->cur = ti; - /* Zooming focuses what it zoomed: the pane filling the tab is the only one - * you could be typing into, and leaving focus elsewhere would mean keys - * going somewhere invisible. */ - a->tabs[ti].zoom = a->tabs[ti].zoom == n->id ? 0 : n->id; - a->tabs[ti].focus = n; - layout(a); - return true; -} - -bool app_pane_zoomed(app_t *a, uint32_t id) { - return a->ntabs && cur(a)->zoom == id && id != 0; -} - -void app_set_session(app_t *a, const char *name) { - snprintf(a->session, sizeof a->session, "%s", name ? name : ""); -} - -bool app_focus_pane(app_t *a, uint32_t id) { - node_t *n = pane_by_id(a, id); - if (!n) return false; - size_t ti = tab_of(a, n); - if (ti == (size_t)-1) return false; - a->cur = ti; - a->tabs[ti].focus = n; - layout(a); - return true; -} - -bool app_split_pane(app_t *a, uint32_t id, bool rows) { - if (id && !app_focus_pane(a, id)) return false; - split_focus(a, rows ? SPLIT_ROWS : SPLIT_COLS); - return true; -} - -/* Take a leaf out of its tree and leave it whole: the surgery `close_leaf` does, - * without the funeral. The pane keeps running the whole time -- nothing is - * re-spawned by a move, which is the entire point of moving it rather than opening - * one somewhere else and closing this. - * - * `*emptied` says the tab has nothing left in it. Removing it is the caller's, not - * because that is tidier but because removing a tab shifts every index after it, - * and a caller holding one has to know. */ -static bool detach_leaf(app_t *a, node_t *leaf, size_t *from, bool *emptied) { - size_t ti = tab_of(a, leaf); - if (ti == (size_t)-1) return false; - tab_t *t = &a->tabs[ti]; - *from = ti; - *emptied = false; - - /* Nothing about this pane should still say where it used to be. A zoom is the - * tab's opinion about one of its panes, and a minimised pane is one this tab put - * away; carried across, the first would zoom a pane that has left and the second - * would land the arrival in a strip nobody asked for. */ - if (t->zoom == leaf->id) t->zoom = 0; - leaf->minimized = false; - - node_t *p = leaf->parent; - if (!p) { - t->root = NULL; - t->focus = NULL; - *emptied = true; - return true; - } - - size_t at = 0; - while (at < p->nkids && p->kids[at] != leaf) at++; - memmove(&p->kids[at], &p->kids[at + 1], - (p->nkids - at - 1) * sizeof *p->kids); - p->nkids--; - leaf->parent = NULL; - - node_t *survivor = p->kids[0]; - if (p->nkids == 1) { /* a split with one child is just that child */ - survivor->parent = p->parent; - if (p->parent) - replace_child(p->parent, p, survivor); - else - t->root = survivor; - free(p->kids); - free(p); - } - if (t->focus == leaf || t->focus == p) t->focus = first_leaf(survivor); - return true; -} - -/* Move a pane into another tab, beside whatever that tab has focused. - * - * 0 means the focused pane, as everywhere. The destination is named by *id* rather - * than index on purpose: emptying the source tab removes it, and every index after - * it moves -- an id survives that and an index quietly means a different tab. - * - * False when there is nothing to do or nowhere to do it: no such pane, no such tab, - * or the pane is already in that tab. The tab you are looking at does not change; - * whether a caller follows the pane is a question about intent, and this is the - * mechanism. */ -bool app_move_pane_to_tab(app_t *a, uint32_t pane_id, uint32_t tab_id, - bool rows) { - node_t *leaf = pane_id ? pane_by_id(a, pane_id) : cur(a)->focus; - if (!leaf || leaf->kind != NODE_LEAF) return false; - tab_t *dest = tab_by_id(a, tab_id); - if (!dest || !dest->root) return false; - size_t src_ti = tab_of(a, leaf); - if (src_ti == (size_t)-1 || &a->tabs[src_ti] == dest) return false; - - uint32_t dest_id = dest->id, cur_id = a->tabs[a->cur].id; - size_t from; - bool emptied = false; - if (!detach_leaf(a, leaf, &from, &emptied)) return false; - if (emptied) tab_remove(a, from); - - /* Re-found by id, because the removal above may have moved it. */ - dest = tab_by_id(a, dest_id); - if (!dest) { /* cannot happen: the destination is not the tab we emptied */ - node_free(leaf); - return false; - } - node_t *beside = dest->focus ? dest->focus : first_leaf(dest->root); - place_beside(a, dest, beside, leaf, rows ? SPLIT_ROWS : SPLIT_COLS, false); - dest->focus = leaf; - /* A zoomed destination would hide the arrival behind the pane filling it, which - * looks exactly like the move having failed. */ - dest->zoom = 0; - - /* Stay where we were looking, unless that tab is the one that just went. */ - for (size_t i = 0; i < a->ntabs; i++) - if (a->tabs[i].id == cur_id) a->cur = i; - layout(a); - return true; -} - -/* The same, into a tab of its own. Returns the new tab's id, or 0. - * - * Worth having as its own call rather than "move to a tab you make first": a pane - * that is the only thing in its tab would otherwise have its tab removed from under - * the new one, and the order in which those two happen is exactly the bug. */ -uint32_t app_move_pane_to_new_tab(app_t *a, uint32_t pane_id, - const char *name) { - node_t *leaf = pane_id ? pane_by_id(a, pane_id) : cur(a)->focus; - if (!leaf || leaf->kind != NODE_LEAF) return 0; - size_t src_ti = tab_of(a, leaf); - if (src_ti == (size_t)-1) return 0; - /* A pane alone in its tab is already in a tab of its own. */ - if (!leaf->parent) return 0; - - uint32_t cur_id = a->tabs[a->cur].id; - size_t from; - bool emptied = false; - if (!detach_leaf(a, leaf, &from, &emptied)) return 0; - if (emptied) tab_remove(a, from); - - tab_t *t = tab_add(a, name); - t->root = leaf; - t->focus = leaf; - leaf->parent = NULL; - leaf->weight = WEIGHT_UNIT; - uint32_t id = t->id; - - for (size_t i = 0; i < a->ntabs; i++) - if (a->tabs[i].id == cur_id) a->cur = i; - layout(a); - return id; -} - -bool app_close_pane(app_t *a, uint32_t id) { - node_t *n = id ? pane_by_id(a, id) : cur(a)->focus; - if (!n) return false; - close_leaf(a, n); - return true; -} - -/* Run a dead pane's command again, in the pane it died in. - * - * The pane is the same pane throughout — same id, same place in the tree, - * same terminal — so nothing that referred to it has to be told anything, and - * the run that ended stays above the new one in its scrollback. A suspended - * pane is started rather than restarted, because "run the thing this pane is - * for" is the same request either way. */ -/* Open the config in $EDITOR, in a pane of its own. - * - * The whole point is the loop: edit, save, watch the session change under you, - * edit again. Anything that makes you leave the session to do it -- another - * terminal, or remembering where the file lives -- is friction in the middle - * of the one workflow the config watcher exists for. - * - * The pane is ephemeral: quit the editor and it is gone. You asked for an - * editor, not for a record of having had one. */ -bool app_edit_config(app_t *a) { - node_t *n = a->ntabs ? cur(a)->focus : NULL; - if (!n) return false; - - const char *path = config_default_path(); - - /* An editor opened on a file that is not there is a blank buffer, and a - * blank buffer does not tell you what you can set. So write the defaults - * out first: every knob with its default, generated from the code rather - * than from a copy of it. Only when there is nothing there -- - * this must never touch a config somebody has written. */ - if (access(path, F_OK) != 0) { - char dir[1024]; - snprintf(dir, sizeof dir, "%s", path); - char *slash = strrchr(dir, '/'); - if (slash) { - *slash = 0; - path_mkdirs(dir); - } - FILE *f = fopen(path, "wx"); /* x: lose the race rather than an edit */ - if (f) { - char *text = config_dump_defaults(); - fputs(text, f); - free(text); - fclose(f); - app_toast(a, "wrote a starting config"); - } - } - - const char *editor = - CFG.editor && *CFG.editor ? CFG.editor : getenv("EDITOR"); - /* vi is the one editor a POSIX system is required to have. Better a wrong - * guess you can see and change than a pane that opens empty. */ - if (!editor || !*editor) editor = "vi"; - - char cmd[1024]; - snprintf(cmd, sizeof cmd, "%s '%s'", editor, path); - const char *argv[] = {"/bin/sh", "-c", cmd, NULL}; - - /* Down rather than across: a config file is lines, and half the width of a - * pane is a worse place to read them than half the height. */ - if (!split_fits(n, SPLIT_ROWS)) { - app_toast(a, "no room to split down"); - return false; - } - node_t *fresh = split_node_with(a, n, SPLIT_ROWS, false, argv, cmd); - if (!fresh) return false; - pane_set_ephemeral(fresh->pane, true); - return true; -} - -bool app_rerun_pane(app_t *a, uint32_t id) { - node_t *n = id ? pane_by_id(a, id) : (a->ntabs ? cur(a)->focus : NULL); - if (!n) return false; - bool ok = - pane_suspended(n->pane) ? pane_start(n->pane) : pane_restart(n->pane); - if (!ok) { - app_toast(a, "cannot run it again"); - return false; - } - /* The new pty is born at the pane's current size: a dead pane is still laid - * out and still resized, pane_resize() just had no pty to tell. */ - cur(a)->focus = n; - return true; -} - -uint32_t app_focused_pane_id(app_t *a) { - return a->ntabs && cur(a)->focus ? cur(a)->focus->id : 0; -} - -uint32_t app_current_tab_id(app_t *a) { return a->ntabs ? cur(a)->id : 0; } - -/* ---- layouts ------------------------------------------------------------ * - * - * layout { - * tab name="api" purpose="project:api.a1b2" cwd="/home/user/dev/api" { - * pane purpose="agent:main" command="pi" suspended=true - * pane split="rows" { - * pane command="npm run dev" suspended=true - * pane - * } - * } - * } - * - * A `pane` with children is a split; `split` on it picks the direction. Every - * purpose a layout declares is locked, which is the point: identity comes from - * the layout, not from whatever the program inside decides to print (D8). - * - * `base` is the directory the file came from, and a relative `cwd=` resolves - * against it, so a layout can be checked in beside the project it describes - * (path_resolve). A layout that arrived as text has no base and keeps the old - * meaning: relative to wherever the session is. - */ - -static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd, - const char *base) { - /* Resolved once here, so both the pane spawned from this node and every - * child that inherits the value get the same real directory. The buffer - * outlives the recursion below it: children finish before we return. An - * inherited value is already resolved, so it is passed through untouched -- - * resolving it twice would re-root a path against itself. */ - char cwdbuf[1024]; - const char *own = kdl_prop(node, "cwd", NULL); - const char *node_cwd = - own ? path_resolve(own, base, cwdbuf, sizeof cwdbuf) : cwd; - - /* a split: children, in order, in one direction */ - size_t kids = 0; - for (size_t i = 0; i < node->nkids; i++) - if (strcmp(node->kids[i]->name, "pane") == 0) kids++; - - if (kids) { - node_t *sp = calloc(1, sizeof *sp); - sp->kind = NODE_SPLIT; - sp->id = ++a->next_id; - /* A dumped layout carries the proportions it had; a hand-written one says - * nothing and means "even", which is what WEIGHT_UNIT is. */ - sp->weight = (int)kdl_prop_int(node, "weight", WEIGHT_UNIT); - if (sp->weight < WEIGHT_MIN) sp->weight = WEIGHT_UNIT; - sp->dir = strcmp(kdl_prop(node, "split", "cols"), "rows") == 0 ? SPLIT_ROWS - : SPLIT_COLS; - for (size_t i = 0; i < node->nkids; i++) { - if (strcmp(node->kids[i]->name, "pane") != 0) continue; - node_t *kid = build_pane(a, node->kids[i], node_cwd, base); - if (!kid) continue; - sp->kids = realloc(sp->kids, (sp->nkids + 1) * sizeof *sp->kids); - sp->kids[sp->nkids++] = kid; - kid->parent = sp; - } - if (sp->nkids == 0) { - free(sp); - return NULL; - } - if (sp->nkids == 1) { /* a split of one is just the pane */ - node_t *only = sp->kids[0]; - only->parent = NULL; - free(sp->kids); - free(sp); - return only; - } - return sp; - } - - const char *command = kdl_prop(node, "command", NULL); - bool suspended = a->force_suspend || kdl_prop_bool(node, "suspended", false); - const char *argv[4]; - if (command) { - argv[0] = "/bin/sh"; - argv[1] = "-c"; - argv[2] = command; - argv[3] = NULL; - } else { - const char *const *shell = default_argv(a); - argv[0] = shell[0]; - argv[1] = NULL; - for (size_t i = 1; shell[i] && i < 3; i++) argv[i] = shell[i]; - } - - node_t *leaf = leaf_new_ex(a, command ? argv : default_argv(a), node_cwd, - suspended, command ? command : ""); - if (!leaf) return NULL; - leaf->weight = (int)kdl_prop_int(node, "weight", WEIGHT_UNIT); - if (leaf->weight < WEIGHT_MIN) leaf->weight = WEIGHT_UNIT; - /* `focus=true` restores which pane you were in. Recorded on the node and - * resolved once the tab exists, because focus belongs to the tab. */ - if (kdl_prop_bool(node, "focus", false)) a->restore_focus = leaf; - const char *purpose = kdl_prop(node, "purpose", NULL); - if (purpose) { - sanitise_purpose(purpose, leaf->purpose, sizeof leaf->purpose); - leaf->purpose_locked = true; /* declared by a layout: in-band cannot win */ - } - return leaf; -} - -bool app_apply_layout(app_t *a, const kdl_node_t *root, bool replace, - const char *base, char *err, size_t errcap) { - a->restore_tab = (size_t)-1; - const kdl_node_t *lay = kdl_child(root, "layout"); - if (!lay) lay = root; /* allow a bare list of tabs */ - - size_t before = a->ntabs; - size_t made = 0; - for (size_t i = 0; i < lay->nkids; i++) { - const kdl_node_t *t = lay->kids[i]; - if (strcmp(t->name, "tab") != 0) continue; - - tab_t *tab = tab_add(a, kdl_prop(t, "name", "")); - bool active = kdl_prop_bool(t, "active", false); - const char *purpose = kdl_prop(t, "purpose", NULL); - if (purpose) { - sanitise_purpose(purpose, tab->purpose, sizeof tab->purpose); - tab->purpose_locked = true; - } - - /* The tab body is a split of its pane children, and the tab's own props - * are its root's -- including `cwd`, which build_pane reads off the body - * for itself. `base` as the inherited value is what makes a layout with no - * `cwd` anywhere in it start in the directory it was checked into. */ - kdl_node_t body = {.name = (char *)"pane", - .kids = t->kids, - .nkids = t->nkids, - .props = t->props, - .nprops = t->nprops}; - node_t *tree = build_pane(a, &body, base, base); - if (!tree) tree = leaf_new(a); - if (!tree) { - a->ntabs--; - if (err) snprintf(err, errcap, "cannot create panes for tab %zu", i + 1); - return false; - } - tab->root = tree; - tab->focus = a->restore_focus ? a->restore_focus : first_leaf_of(tree); - a->restore_focus = NULL; - /* `active=true` restores which tab you were looking at. Resolved by index - * because the tab was appended to whatever was already there. */ - if (active) a->restore_tab = (size_t)(tab - a->tabs); - made++; - } - - if (!made) { - /* The other half of telling the two documents apart. A file with `theme` or - * `keys` at the top of it is somebody's config, and "declares no tabs" is true - * of it in the least useful way -- the same message a layout gets when its own - * tabs are misspelled. `config_is_setting` answers from the loader's own list, - * so this cannot drift from what a config actually holds. */ - for (size_t i = 0; i < root->nkids; i++) { - const kdl_node_t *n = root->kids[i]; - if (n && n->name && config_is_setting(n->name)) { - if (err) - snprintf(err, errcap, - "this is a config, not a layout: `%s` is a setting", - n->name); - return false; - } - } - if (err) snprintf(err, errcap, "layout declares no tabs"); - return false; - } - - if (replace) { /* drop the tabs that existed before this layout */ - for (size_t i = 0; i < before; i++) node_free(a->tabs[i].root); - memmove(&a->tabs[0], &a->tabs[before], made * sizeof *a->tabs); - a->ntabs = made; - } - /* Which tab to land on: the one a dump marked `active`, if it named one, - * and otherwise the first of what was just built. Setting it unconditionally - * here is what quietly undid the restore. - * - * The index was recorded while the new tabs sat *after* the old ones, and - * `replace` has just moved them to the front -- so it shifts by exactly the - * number that were dropped. Off by that, it lands on the wrong tab, which - * looks like the restore working badly rather than not at all. */ - if (a->restore_tab != (size_t)-1 && replace && a->restore_tab >= before) - a->restore_tab -= before; - if (a->restore_tab != (size_t)-1 && a->restore_tab < a->ntabs) - a->cur = a->restore_tab; - else - a->cur = replace ? 0 : before; - a->restore_tab = (size_t)-1; - layout(a); - return true; -} - -bool app_apply_layout_text_at(app_t *a, const char *text, bool replace, - const char *base, char *err, size_t errcap) { - kdl_node_t *root = kdl_parse(text, err, errcap); - if (!root) return false; - bool ok = app_apply_layout(a, root, replace, base, err, errcap); - kdl_free(root); - return ok; -} - -bool app_apply_layout_text(app_t *a, const char *text, bool replace, char *err, - size_t errcap) { - /* No base: text has no directory it came from, so a relative `cwd=` in it - * keeps meaning what it meant before there was a base to be relative to. */ - return app_apply_layout_text_at(a, text, replace, NULL, err, errcap); -} - -bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err, - size_t errcap) { - /* Expanded here rather than left to a shell: `--layout "~/x.layout.kdl"` in - * quotes, and every path arriving over the control socket, reach fopen with - * the tilde still on them. */ - char pathbuf[1024], dirbuf[1024]; - const char *file = path_expand(path, pathbuf, sizeof pathbuf); - kdl_node_t *root = kdl_parse_file(file, err, errcap); - if (!root) return false; - bool ok = app_apply_layout( - a, root, replace, path_dir(file, dirbuf, sizeof dirbuf), err, errcap); - kdl_free(root); - return ok; -} - -/* ---- checking one -------------------------------------------------------- * - * - * The other half of D2's promise: a document says which one it is, and a name - * the loader does not read is reported rather than skipped. build_pane() above - * asks for props by name and ignores the rest, which is right for loading -- - * a session should start -- and useless for the person who wrote `cmd=` where - * `command=` was meant and got a shell. - * - * These lists sit next to the code that reads them on purpose, and - * `tests/test_layout_check.py` greps this file for every `kdl_prop*(node, ...)` - * the layout section asks for and fails if one is missing here -- the same - * arrangement that keeps KNOWN_TOP honest in config.c. - */ - -/* Read by build_pane, on a `pane` node or on a tab acting as its own root. */ -static const char *const PANE_PROPS[] = { - "split", "weight", "cwd", "command", "focus", "purpose", "suspended"}; -/* Read by app_apply_layout off the tab itself. */ -static const char *const TAB_PROPS[] = {"name", "active"}; - -static bool in_list(const char *const *list, size_t n, const char *name) { - for (size_t i = 0; i < n; i++) - if (strcmp(list[i], name) == 0) return true; - return false; -} - -#define NELEM(x) (sizeof(x) / sizeof(*(x))) - -typedef struct { - const char *file; - layout_msg_t *msgs; - size_t max, n, dropped; -} lcheck_t; - -static void lc_say(lcheck_t *c, int line, const char *fmt, ...) { - if (c->n >= c->max) { - c->dropped++; - return; - } - char text[160]; - va_list ap; - va_start(ap, fmt); - vsnprintf(text, sizeof text, fmt, ap); - va_end(ap); - snprintf(c->msgs[c->n++], sizeof *c->msgs, "%s:%d: %s", c->file, line, text); -} - -/* `true`/`false` and nothing else: kdl_prop_bool falls back silently, so - * `suspended=yes` is a pane that quietly starts. */ -static void lc_bool(lcheck_t *c, const kdl_node_t *n, const char *key) { - const char *v = kdl_prop(n, key, NULL); - if (v && strcmp(v, "true") != 0 && strcmp(v, "false") != 0) - lc_say(c, n->line, "%s takes true or false, not `%s`", key, v); -} - -static void lc_props(lcheck_t *c, const kdl_node_t *n, bool is_tab) { - size_t kids = 0; - for (size_t i = 0; i < n->nkids; i++) - if (strcmp(n->kids[i]->name, "pane") == 0) kids++; - - for (size_t i = 0; i < n->nprops; i++) { - const char *k = n->props[i].key; - bool known = in_list(PANE_PROPS, NELEM(PANE_PROPS), k) || - (is_tab && in_list(TAB_PROPS, NELEM(TAB_PROPS), k)); - if (!known) { - lc_say(c, n->line, "unknown %s property: %s", is_tab ? "tab" : "pane", k); - continue; - } - /* A split reads `split=` and its children; a leaf reads what it runs. The - * wrong half is dropped without a word, which is how `purpose=` on a split - * comes to tag nothing at all. A tab reads its own name and purpose either - * way, so those are never the ignored ones. */ - bool leaf_only = strcmp(k, "command") == 0 || strcmp(k, "suspended") == 0 || - strcmp(k, "focus") == 0 || - (!is_tab && strcmp(k, "purpose") == 0); - if (kids && leaf_only) - lc_say(c, n->line, "%s is ignored on a %s with panes in it", k, - is_tab ? "tab" : "pane"); - if (!kids && strcmp(k, "split") == 0) - lc_say(c, n->line, "split is ignored on a pane with nothing to split"); - } - - const char *dir = kdl_prop(n, "split", NULL); - if (dir && strcmp(dir, "cols") != 0 && strcmp(dir, "rows") != 0) - lc_say(c, n->line, "split is cols or rows, not `%s`", dir); - - const char *w = kdl_prop(n, "weight", NULL); - if (w) { - char *end = NULL; - long v = strtol(w, &end, 10); - if (!end || *end || v < WEIGHT_MIN) - lc_say(c, n->line, "weight is a number of %d or more, not `%s`", - WEIGHT_MIN, w); - } - - lc_bool(c, n, "suspended"); - lc_bool(c, n, "focus"); - if (is_tab) lc_bool(c, n, "active"); - - for (size_t i = 0; i < n->nkids; i++) { - const kdl_node_t *k = n->kids[i]; - if (strcmp(k->name, "pane") != 0) { - lc_say(c, k->line, "a %s holds panes, not `%s`", is_tab ? "tab" : "pane", - k->name); - continue; - } - lc_props(c, k, false); - } -} - -size_t layout_check(const kdl_node_t *root, const char *file, - layout_msg_t *msgs, size_t max, size_t *dropped) { - lcheck_t c = {.file = file, .msgs = msgs, .max = max}; - const kdl_node_t *lay = kdl_child(root, "layout"); - size_t tabs = 0; - - for (size_t i = 0; i < root->nkids; i++) { - const kdl_node_t *n = root->kids[i]; - if (!n || !n->name) continue; - if (lay && n == lay) continue; - if (strcmp(n->name, "tab") == 0) - continue; /* a bare list of tabs is legal */ - if (config_is_setting(n->name)) { - lc_say(&c, n->line, "this is a config, not a layout: `%s` is a setting", - n->name); - continue; - } - lc_say(&c, n->line, "unknown node: %s", n->name); - } - - if (!lay) lay = root; - for (size_t i = 0; i < lay->nkids; i++) { - const kdl_node_t *n = lay->kids[i]; - if (!n || !n->name) continue; - if (strcmp(n->name, "tab") != 0) { - if (lay != root) - lc_say(&c, n->line, "a layout holds tabs, not `%s`", n->name); - continue; - } - tabs++; - lc_props(&c, n, true); - } - - /* Last, so it reads as the summary it is rather than the first surprise. */ - if (!tabs) lc_say(&c, lay->line, "this layout declares no tabs"); - if (dropped) *dropped = c.dropped; - return c.n; -} - -size_t layout_check_file(const char *path, layout_msg_t *msgs, size_t max, - size_t *dropped) { - char pathbuf[1024]; - const char *file = path_expand(path, pathbuf, sizeof pathbuf); - const char *base = strrchr(file, '/'); - base = base ? base + 1 : file; - - char err[256] = {0}; - kdl_node_t *root = kdl_parse_file(file, err, sizeof err); - if (!root) { - /* kdl reports `line N: what`, with no filename because it never saw one, - * and `cannot open X` for the other kind of failure -- which carries its - * own path and no line. Same `file:line: text` shape as everything else - * either way, so an editor's compile step reads all of them. */ - if (max) { - int line = 0; - if (sscanf(err, "line %d:", &line) == 1) - snprintf(msgs[0], sizeof *msgs, "%s:%d:%s", base, line, - strchr(err, ':') + 1); - else - snprintf(msgs[0], sizeof *msgs, "%s", err); - } - if (dropped) *dropped = 0; - return max ? 1 : 0; - } - size_t n = layout_check(root, base, msgs, max, dropped); - kdl_free(root); - return n; -} - -/* ---- workspaces ---------------------------------------------------------- * - * - * A project is a directory (project.c). A workspace is the tab it occupies here. - * Membership is the tab's *purpose*, in the `project:` namespace -- the shape - * this repo has published as a project tab's identity since D3 -- so there is no - * second answer to "what is this tab" and a dumped layout restores membership - * for free, because a dump already writes tab purposes and apply-layout already - * locks them (D8). - * - * Opening is idempotent, which is `sl0ppi up`'s property carried over: the same - * request twice is one workspace, focused. Everything else follows from that -- - * a script can drive it in a loop without asking first, and so can a keystroke. - */ - -/* Which roots to scan and how deep, from the config in force. Read fresh each - * time rather than cached, because saving the config is allowed to change where - * projects live and a cache would answer with yesterday's ~/dev. */ -static const char *const *workspace_roots(int *depth) { - static const char *roots[PROJECT_ROOTS_MAX + 1]; - size_t n = 0; - for (; n < CFG.nproject_roots && n < PROJECT_ROOTS_MAX; n++) - roots[n] = CFG.project_roots[n]; - roots[n] = NULL; - if (depth) *depth = CFG.project_depth; - return roots; -} - -bool app_project_roots_set(void) { return CFG.nproject_roots > 0; } - -size_t app_projects(project_t *out, size_t max) { - int depth = 2; - const char *const *roots = workspace_roots(&depth); - return project_scan(roots, depth, out, max); -} - -/* The first tab holding this workspace, or NULL: a workspace whose layout - * declared several tabs has several, and the first is the one to land in. */ -static tab_t *workspace_tab(app_t *a, const char *slug) { - for (size_t i = 0; i < a->ntabs; i++) - if (strcmp(a->tabs[i].purpose, slug) == 0) return &a->tabs[i]; - return NULL; -} - -uint32_t app_workspace_tab(app_t *a, const char *slug) { - tab_t *t = workspace_tab(a, slug); - return t ? t->id : 0; -} - -/* Make every tab this apply just built a member of the workspace. - * - * A tab the layout gave a purpose of its own keeps it and is not a member -- - * honoured rather than overwritten, because overwriting a declared purpose is - * the one thing D8 forbids. The count comes back so the caller can say so - * instead of it being a silent surprise. */ -static void adopt_tabs(app_t *a, size_t from, const project_t *p, - app_workspace_open_t *out) { - for (size_t i = from; i < a->ntabs; i++) { - tab_t *t = &a->tabs[i]; - if (t->purpose[0] && strncmp(t->purpose, "project:", 8) != 0) { - out->honoured++; - continue; - } - sanitise_purpose(p->slug, t->purpose, sizeof t->purpose); - t->purpose_locked = true; - /* A tab with no name of its own takes the project's: a strip reading - * `1 2 3` is not navigation, which is the whole complaint workspaces - * answer. */ - if (!t->name[0]) snprintf(t->name, sizeof t->name, "%s", p->name); - if (!out->tab) out->tab = t->id; - out->tabs++; - } -} - -bool app_workspace_find(const char *name, project_t *out) { - int depth = 2; - const char *const *roots = workspace_roots(&depth); - return project_find(roots, depth, name, out); -} - -bool app_workspace_open(app_t *a, const char *name, bool suspended, - app_workspace_open_t *out, char *err, size_t errcap) { - memset(out, 0, sizeof *out); - project_t p; - if (!app_workspace_find(name, &p)) { - snprintf(err, errcap, - CFG.nproject_roots ? "no project called %s" - : "no project roots: set project_roots in your " - "config (asked for %s)", - name); - return false; - } - snprintf(out->purpose, sizeof out->purpose, "%s", p.slug); - snprintf(out->path, sizeof out->path, "%s", p.path); - - /* Already open: focus it. The same request twice is one workspace, and the - * answer says which branch it took so a caller never has to ask first. */ - tab_t *have = workspace_tab(a, p.slug); - if (have) { - out->tab = have->id; - app_select_tab_id(a, have->id); - return true; - } - - /* `suspended` has to be decided before anything spawns: a pane is created - * suspended or it is created running, and there is no un-running a process. - * Carried on the app for the duration of one apply, the way restore_focus and - * restore_tab already are, rather than widening two signatures for it. */ - a->force_suspend = suspended; - size_t before = a->ntabs; - bool ok; - if (p.layout[0]) { - ok = app_apply_layout_file(a, p.layout, false, err, errcap); - } else if (CFG.project_layout) { - /* The base is the *project*, not the directory this file came from: the - * point of a shared project layout is that `cwd="."` means whichever - * project is being opened. Relative to itself it would open every project - * in ~/.config. */ - char buf[1024]; - kdl_node_t *root = kdl_parse_file( - path_expand(CFG.project_layout, buf, sizeof buf), err, errcap); - ok = root && app_apply_layout(a, root, false, p.path, err, errcap); - kdl_free(root); - } else { - /* No file anywhere: one pane, your shell, in the project. Which is the - * least a `.git` with nothing else in it can honestly be opened as. */ - char kdl[256]; - snprintf(kdl, sizeof kdl, "layout { tab { pane } }"); - ok = app_apply_layout_text_at(a, kdl, false, p.path, err, errcap); - } - a->force_suspend = false; - if (!ok) return false; - - adopt_tabs(a, before, &p, out); - out->created = true; - if (out->tab) app_select_tab_id(a, out->tab); - layout(a); - return true; -} - -size_t app_workspace_close(app_t *a, const char *slug) { - size_t closed = 0; - /* Re-found each time rather than collected first: closing a tab moves every - * index after it, and a list of pointers taken beforehand would be stale by - * the second one. */ - for (;;) { - tab_t *t = workspace_tab(a, slug); - if (!t || !app_close_tab(a, t->id)) break; - closed++; - } - return closed; -} - -/* Which project a tab belongs to, or is standing in. - * - * Three answers in order: the path asked for, the project whose slug this tab - * already carries, and the directory the focused pane is in. All three are - * resolved by *scanning*, which is also the containment check -- a directory no - * root holds is one this session will not write a layout into, and that falls - * out of asking the same question the picker asks rather than being a rule of - * its own. */ -static bool tab_project(app_t *a, const tab_t *t, const char *path, - project_t *out) { - int depth = 2; - const char *const *roots = workspace_roots(&depth); - if (path && *path) return project_find(roots, depth, path, out); - - if (strncmp(t->purpose, "project:", 8) == 0) { - project_t all[PROJECTS_MAX]; - size_t n = project_scan(roots, depth, all, PROJECTS_MAX); - for (size_t i = 0; i < n; i++) - if (strcmp(all[i].slug, t->purpose) == 0) { - *out = all[i]; - return true; - } - } - - if (!t->focus) return false; - char cwdbuf[4096]; - return project_find(roots, depth, - live_cwd(t->focus->pane, cwdbuf, sizeof cwdbuf), out); -} - -bool app_workspace_save(app_t *a, uint32_t tab, const char *path, int suspend, - bool force, app_workspace_save_t *out, char *err, - size_t errcap) { - memset(out, 0, sizeof *out); - tab_t *t = tab ? tab_by_id(a, tab) : cur(a); - if (!t) { - snprintf(err, errcap, "no such tab"); - return false; - } - - project_t p; - if (!tab_project(a, t, path, &p)) { - snprintf(err, errcap, "this tab is not in a project root"); - return false; - } - /* A tab that is already *a* workspace may only be saved into its own project. - * Saving it elsewhere would leave one tab claiming two projects -- named for - * one, carrying the other's purpose -- which is the second source of truth - * this whole design exists to avoid. Almost always it is a `path` typed while - * the wrong tab was focused, so it is said rather than obeyed. */ - if (strncmp(t->purpose, "project:", 8) == 0 && - strcmp(t->purpose, p.slug) != 0) { - snprintf( - err, errcap, - "this tab is another project's workspace (%s): save it from its own" - " tab, or pass that tab's id", - t->purpose); - return false; - } - snprintf(out->path, sizeof out->path, "%s/%s", p.path, PROJECT_LAYOUT_FILE); - out->replaced = p.layout[0] != 0; - if (out->replaced && !force) { - snprintf(err, errcap, "%s already has a layout: pass force to replace it", - p.name); - return false; - } - - dump_layout_t o = { - .tab = t->id, .base = p.path, .suspend = suspend, .for_project = true}; - char *kdl = app_dump_layout(a, &o); - if (!kdl || !o.tabs) { - free(kdl); - snprintf(err, errcap, "nothing to write"); - return false; - } - FILE *f = fopen(out->path, "w"); - if (!f) { - free(kdl); - snprintf(err, errcap, "cannot write %s", out->path); - return false; - } - /* One line of provenance, and the thing to run when it stops working. No - * timestamp, for the same reason the dump has none: a file that differs every - * time is a bad diff, and this one is meant to be committed. */ - fputs("// What this project needs open, written by `save-workspace`.\n" - "// Checked by `slosh --check`; opened by `open-workspace`.\n", - f); - fputs(kdl, f); - bool wrote = fclose(f) == 0; - free(kdl); - if (!wrote) { - snprintf(err, errcap, "cannot write %s", out->path); - return false; - } - - out->panes = o.panes; - out->suspended = o.suspended; - /* Saving is also adopting: the tab that wrote the file is that project's - * workspace from now on, so `C-a W` in a tab you happened to build in a - * checkout is the whole of onboarding one. */ - if (!t->purpose[0] || strncmp(t->purpose, "project:", 8) == 0) { - sanitise_purpose(p.slug, t->purpose, sizeof t->purpose); - t->purpose_locked = true; - if (!t->name[0]) snprintf(t->name, sizeof t->name, "%s", p.name); - } - snprintf(out->purpose, sizeof out->purpose, "%s", t->purpose); - return true; -} - -/* ---- resizing and reordering -------------------------------------------- */ - -static size_t index_in_parent(node_t *n) { - for (size_t i = 0; i < n->parent->nkids; i++) - if (n->parent->kids[i] == n) return i; - return 0; -} - -/* Move weight between two adjacent siblings, refusing to squeeze either out. */ -/* `from` always shrinks. It stops at the floor rather than being pushed under - * one: a pane below the floor collapses the whole tab into a list, and having - * that happen because you nudged a divider one cell too far would read as the - * session falling over rather than as a limit being reached. Every other - * resizable thing simply stops, so this does too. - * - * The clamp lives here because both ways of resizing end up here — the mouse - * through drag_edge and the keyboard through resize_focus — and a limit that - * only one of them respected would be worse than none. */ -static void transfer_weight(node_t *from, node_t *to, int amount) { - long min_weight = WEIGHT_MIN; - - node_t *sp = from->parent; - if (sp && sp->nkids >= 2) { - uint16_t floor_ = sp->dir == SPLIT_COLS ? MIN_PANE_COLS : MIN_PANE_ROWS; - uint16_t gap = - sp->dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect) : CFG.gap; - uint16_t span = sp->dir == SPLIT_COLS ? sp->rect.w : sp->rect.h; - uint16_t gaps = (uint16_t)(gap * (sp->nkids - 1)); - uint16_t avail = span > gaps ? (uint16_t)(span - gaps) : span; - long total = 0; - for (size_t k = 0; k < sp->nkids; k++) total += sp->kids[k]->weight; - /* Rounded up: the weight that buys exactly `floor_` cells is a fraction, - * and rounding down buys one cell fewer — which is the cell that puts the - * pane under the floor. */ - if (avail && total > 0) { - long need = ((long)floor_ * total + avail - 1) / avail; - if (need > min_weight) min_weight = need; - } - } - - if (from->weight - amount < min_weight) - amount = (int)(from->weight - min_weight); - if (amount <= 0) return; /* already at the floor: the nudge does nothing */ - from->weight -= amount; - to->weight += amount; -} - -/* Move the boundary next to the focused pane in the given direction. The pane - * left of a boundary grows when it moves right — which is what a person means - * by "wider", whichever side of it they are on. */ -static void resize_focus(app_t *a, int dx, int dy) { - node_t *n = cur(a)->focus; - if (!n) return; - split_dir_t want = dx ? SPLIT_COLS : SPLIT_ROWS; - while (n->parent && !(n->parent->dir == want && n->parent->nkids >= 2)) - n = n->parent; - if (!n->parent) return; /* nothing to resize against, in that direction */ - - node_t *p = n->parent; - size_t i = index_in_parent(n); - int dir = dx ? dx : dy; - - if (i + 1 < p->nkids) { - if (dir > 0) - transfer_weight(p->kids[i + 1], n, WEIGHT_STEP); - else - transfer_weight(n, p->kids[i + 1], WEIGHT_STEP); - } else if (i > 0) { - if (dir > 0) - transfer_weight(n, p->kids[i - 1], WEIGHT_STEP); - else - transfer_weight(p->kids[i - 1], n, WEIGHT_STEP); - } - layout(a); -} - -/* ---- equalising --------------------------------------------------------- - * - * Every visible pane an even share of the rows and columns it competes for. - * - * A split's children are weighted by *how many visible panes are behind each - * of them*, not given a weight each: one pane beside a column of three is a - * quarter of the width, not half of it. Weighting per child instead would make - * "even" mean something different at every depth — the pane on its own would - * get as much room as the three sharing the other side, which is the layout a - * person asks for this action to get *away* from. - * - * Exactly equal cell counts are not something a tree can always express: a - * nested split pays for its own gaps out of its share, and a share that is not - * a whole number of cells lands where the layout's remainder loop puts it. So - * this is the even division the tree *can* express, which is why the action is - * "give every pane an even share" and not "make every pane the same size". - * - * Minimised panes are not counted. They are out of the layout entirely and - * cost a row in the strip rather than a share of the tree, so counting one - * would hand its share to a pane nobody can see. Their own weight is still - * evened, because that is the share they come back to. - * - * Returns the number of visible panes under `n`. */ -static size_t equalize_node(node_t *n) { - if (n->kind == NODE_LEAF) { - n->weight = WEIGHT_UNIT; - return n->minimized ? 0 : 1; - } - size_t vis = 0; - for (size_t i = 0; i < n->nkids; i++) vis += equalize_node(n->kids[i]); - /* A subtree with nothing visible in it is not laid out at all, so its weight - * is never divided by — but it must not be zero either, or restoring the - * pane inside it would give it no share to come back to. */ - n->weight = (int)(WEIGHT_UNIT * (vis ? vis : 1)); - return vis; -} - -/* The current tab, like zooming and minimising: this is about the arrangement - * you are looking at. False when there are no boundaries to move, so the - * caller can say so rather than leaving a keystroke looking broken. */ -bool app_equalize_splits(app_t *a) { - node_t *root = cur(a)->root; - if (!root || root->kind == NODE_LEAF) return false; - equalize_node(root); - layout(a); - return true; -} - -/* ---- rotating ----------------------------------------------------------- - * - * A quarter turn clockwise, of the whole tab. - * - * Every split changes axis, and one of the two directions also reverses its - * children — because that is what a quarter turn does to a stack. Turn a column - * of panes clockwise and the one that was on top is now the one on the *right*, - * so a row split becomes a column split read backwards; turn a row of them and - * the leftmost becomes the top, so the order stands. Getting that asymmetry - * wrong gives you a mirror image, which looks almost right and is not a - * rotation: four turns would not come back. - * - * Four turns *do* come back, exactly, which is what makes this safe to press: - * it is a permutation of the tree and nothing else. Weights travel with the - * children they belong to, so a pane that had two thirds of the width has two - * thirds of the height afterwards. - * - * Depth first, so a subtree is turned before the split above it reorders it — - * the two operations are independent, but doing the children first keeps the - * reversal reading as one thing rather than as something that has to be - * reasoned about twice. */ -static void rotate_node(node_t *n) { - if (!n || n->kind == NODE_LEAF) return; - for (size_t i = 0; i < n->nkids; i++) rotate_node(n->kids[i]); - - if (n->dir == SPLIT_ROWS) { - for (size_t i = 0, j = n->nkids ? n->nkids - 1 : 0; i < j; i++, j--) { - node_t *t = n->kids[i]; - n->kids[i] = n->kids[j]; - n->kids[j] = t; - } - n->dir = SPLIT_COLS; - } else { - n->dir = SPLIT_ROWS; - } -} - -/* The current tab, like zooming and equalising. False when there is nothing to - * turn — one pane looks the same from every angle — and false when the turn - * would not fit, which is the interesting case. - * - * A pane's share of one axis is not a share the other axis can always afford: - * two rows split 19:6 are fine as rows and put the smaller one under the column - * floor as columns, and a node that cannot give its children their floor takes - * the *whole tab* down to a list (D6). So the turn is tried, the layout is asked - * what it made of it, and a turn that flattened the tab is put back — the same - * rule `split_fits` applies to splitting, and for the same reason: an action a - * person just asked for should not be the thing that breaks the arrangement. - * - * Put back by turning three more times rather than by a second, mirrored - * rotation. Four turns are the identity, so this *is* the inverse, and the - * asymmetry between the two directions — the part that is easy to get wrong — - * stays written down exactly once. - * - * A tab that was *already* a list is turned anyway: there is nothing to break, - * and refusing would mean a window too small to lay out could never be turned - * back into one that is. */ -bool app_rotate_layout(app_t *a) { - node_t *root = cur(a)->root; - if (!root || root->kind == NODE_LEAF) return false; - - bool was_flat = a->flattened; - rotate_node(root); - layout(a); - if (a->flattened && !was_flat) { - rotate_node(root); - rotate_node(root); - rotate_node(root); - layout(a); - return false; - } - return true; -} - -/* Turning because a person just asked for it, which is the only case that needs - * telling why nothing happened. The two refusals are different facts and get - * different words: one pane has no arrangement to turn, and a turn that would - * flatten the tab was put back. Same split of responsibility as - * `split_focus_ui`: the tree edit is quiet and the UI explains itself. */ -static void rotate_layout_ui(app_t *a) { - bool one_pane = cur(a)->root && cur(a)->root->kind == NODE_LEAF; - if (!app_rotate_layout(a)) - app_toast(a, one_pane ? "nothing to turn" : "no room to turn it"); -} - -/* Reordering is a swap of two leaves, in place: their positions, weights and - * parents trade, and everything else about them is untouched. */ -static bool swap_panes(app_t *a, uint32_t id_a, uint32_t id_b) { - if (id_a == id_b) return false; - node_t *x = pane_by_id(a, id_a), *y = pane_by_id(a, id_b); - if (!x || !y || !x->parent || !y->parent) return false; - - node_t *px = x->parent, *py = y->parent; - size_t ix = index_in_parent(x), iy = index_in_parent(y); - int wx = x->weight, wy = y->weight; - - px->kids[ix] = y; - py->kids[iy] = x; - y->parent = px; - x->parent = py; - y->weight = wx; - x->weight = wy; - layout(a); - return true; -} - -/* ---- focus -------------------------------------------------------------- */ - -struct dirsearch { - node_t *from; - int dx, dy; - node_t *best; - long best_score; -}; - -static void dir_cb(node_t *n, void *ud) { - struct dirsearch *d = ud; - if (n == d->from) return; - /* centres, in a coordinate space where a row counts double so that - * "nearest" means the same thing horizontally and vertically */ - long fx = d->from->rect.x * 2 + d->from->rect.w, - fy = d->from->rect.y * 2 + d->from->rect.h; - long nx = n->rect.x * 2 + n->rect.w, ny = n->rect.y * 2 + n->rect.h; - long along = d->dx ? (nx - fx) * d->dx : (ny - fy) * d->dy; - if (along <= 0) return; /* not in that direction */ - long across = d->dx ? labs(ny - fy) : labs(nx - fx); - long score = along + across * 4; /* prefer straight ahead over diagonal */ - if (!d->best || score < d->best_score) { - d->best = n; - d->best_score = score; - } -} - -static void focus_dir(app_t *a, int dx, int dy) { - if (!cur(a)->focus) return; - struct dirsearch d = {cur(a)->focus, dx, dy, NULL, 0}; - walk(cur(a)->root, dir_cb, &d); - if (d.best) cur(a)->focus = d.best; -} - -struct nextsearch { - node_t *from; - node_t *first, *prev_match, *next; - bool seen; -}; -static void next_cb(node_t *n, void *ud) { - struct nextsearch *s = ud; - if (!s->first) s->first = n; - if (s->seen && !s->next) s->next = n; - if (n == s->from) s->seen = true; -} - -static void focus_next(app_t *a) { - struct nextsearch s = {cur(a)->focus, NULL, NULL, NULL, false}; - walk(cur(a)->root, next_cb, &s); - cur(a)->focus = s.next ? s.next : s.first; -} - -/* ---- drawing ------------------------------------------------------------ */ - -/* A pane's own status line and buttons, drawn in its bottom frame row. - * - * Two things end up here, because they are the same thing: what a *live* pane - * asked for over OSC 5577, and what a *dead* one is offered instead. A dead - * pane's own buttons are inert — clicking one would write a click report into - * a pty that is closed — so the row is given over to the two verbs that do - * still mean something: run it again, or let it go. Same row, same shape, - * same budgeting, so a dead pane is not a second kind of frame. - * - * Buttons are budgeted from the right *before* the status text gets any - * columns, and each registers its hit as it is painted — the same rule the - * frame's own buttons follow, for the same reason. Rightmost is last in the - * list, so what drops off a narrow frame is what is listed first: `close` - * outlives `re-run`, because being unable to dismiss a dead pane is a trap - * and being unable to re-run one is an inconvenience. */ -struct row_btn { - char label[40]; - char action[48]; -}; - -static void draw_pane_status(app_t *a, screen_t *s, node_t *leaf, color_t fg, - bool focused) { - struct row_btn row[8]; - size_t nbtn = 0; - char status[256] = {0}; - bool dead = !pane_alive(leaf->pane) && !pane_suspended(leaf->pane); - - if (dead) { - snprintf(row[nbtn].label, sizeof row[0].label, "re-run"); - snprintf(row[nbtn].action, sizeof row[0].action, "rerun:%u", leaf->id); - nbtn++; - snprintf(row[nbtn].label, sizeof row[0].label, "close"); - snprintf(row[nbtn].action, sizeof row[0].action, "close:%u", leaf->id); - nbtn++; - exit_words(leaf->pane, status, sizeof status); - } else { - const pane_button_t *btns = NULL; - size_t n = pane_buttons(leaf->pane, &btns); - for (size_t i = 0; i < n && nbtn < sizeof row / sizeof *row; i++) { - snprintf(row[nbtn].label, sizeof row[0].label, "%s", btns[i].label); - snprintf(row[nbtn].action, sizeof row[0].action, "btn:%u:%s", leaf->id, - btns[i].id); - nbtn++; - } - snprintf(status, sizeof status, "%s", pane_status(leaf->pane)); - } - if (!nbtn && !*status) return; - - rect_t r = leaf->rect; - if (r.w < 6 || r.h < 3) return; - uint16_t y = (uint16_t)(r.y + r.h - 1); - uint16_t left = (uint16_t)(r.x + 1), right = (uint16_t)(r.x + r.w - 1); - - /* right to left, so a button that does not fit is simply not drawn */ - uint16_t x = right; - for (size_t i = nbtn; i-- > 0;) { - uint16_t w = (uint16_t)(cells(row[i].label) + 2); /* [label] */ - if (x < left + w + 1) break; - x = (uint16_t)(x - w - 1); - char label[80]; - snprintf(label, sizeof label, "[%s]", row[i].label); - bool hot = ptr_on(a, x, y, w, 1); - uint16_t drawn = - screen_text(s, x, y, label, focused || hot ? BTN_FG : fg, - focused || hot ? BTN_BG : BTN_BG_IDLE, hot ? ATTR_BOLD : 0); - hit_add(&s->hits, x, y, drawn, 1, row[i].action); - } - - if (*status && x > left + 1) { - char buf[256]; - int len = snprintf(buf, sizeof buf, " %s ", status); - uint16_t room = (uint16_t)(x - left); - if (len > (int)room) { - len = room; - buf[len] = 0; - } - uint16_t drawn = screen_text( - s, left, y, buf, dead ? DEAD_C : (focused ? TITLE_FOCUS : TITLE_IDLE), - NO_COLOR, dead ? ATTR_BOLD : 0); - /* Claims its cells, the way the name on the top row does. Nothing clicks it - * -- an epitaph is not a button -- but owning the cells is what keeps the - * armed split guide from ruling a line through the one sentence saying how - * this pane died. */ - if (drawn) { - char action[48]; - snprintf(action, sizeof action, "panestatus:%u", leaf->id); - hit_add(&s->hits, left, y, drawn, 1, action); - } - } -} - -/* The split handle: the middle of a border, and the only part of it that - * splits. - * - * The whole side used to be the button, which reads well until you count the - * accidents. A border is the longest target on a screen, it sits between two - * things you did mean to click, and the cost of brushing it was a changed - * layout. The gesture is worth keeping -- the side you click is the side the - * pane arrives on, which no single glyph can say -- so what changes is the size - * of the target: a *place* on the edge rather than the whole edge. The guide - * (`draw_split_guide`) thickens it on hover and reads its rect back from the - * hit list, so there is one opinion about where it is (D6: one geometry). - * - * Three cells is the floor the pane buttons already settled on, for the reason - * written there: a one-cell target is a thing you miss with a mouse. Seven is - * the ceiling, because a third of a tall pane's border is most of that border - * again, and the accidents come back with it. */ -static uint16_t split_handle_len(uint16_t span) { - uint16_t len = (uint16_t)(span / 3); - if (len < 3) len = 3; - if (len > 7) len = 7; - if (len > span) len = span; - return len; -} - -/* One side's handle. False when the side is too short to hold one, which is a - * pane `split_fits` is about to refuse anyway. The top row is not here: its - * placement depends on what the title and the buttons left, so draw_frame does - * it once those are known. */ -static bool split_handle(const node_t *leaf, char side, rect_t *out) { - rect_t r = leaf->rect; - if (r.w < 4 || r.h < 4) return false; - bool vert = side == 'l' || side == 'r'; - /* The span leaves the corners out: a corner is where two gaps cross and is a - * resize target already. */ - uint16_t span = vert ? (uint16_t)(r.h - 2) : (uint16_t)(r.w - 2); - if (span < 3) return false; - uint16_t len = split_handle_len(span); - uint16_t off = (uint16_t)((span - len) / 2); - if (vert) - *out = (rect_t){side == 'l' ? r.x : (uint16_t)(r.x + r.w - 1), - (uint16_t)(r.y + 1 + off), 1, len}; - else - *out = - (rect_t){(uint16_t)(r.x + 1 + off), (uint16_t)(r.y + r.h - 1), len, 1}; - return true; -} - -/* What a press on this side is called. The top row answers to `title:` because - * it is the drag handle too, and one press has to be able to become either. */ -static void split_handle_action(const node_t *leaf, char side, char *buf, - size_t cap) { - if (side == 't') - snprintf(buf, cap, "title:%u:t", leaf->id); - else - snprintf(buf, cap, "border:%u:%c", leaf->id, side); -} - -/* And what the rest of that side is called: the part that arms the guide and - * does nothing when pressed. The top row's rim is the drag handle, so it keeps - * the plain `title:` name it always had. */ -static void split_rim_action(const node_t *leaf, char side, char *buf, - size_t cap) { - if (side == 't') - snprintf(buf, cap, "title:%u", leaf->id); - else - snprintf(buf, cap, "brim:%u:%c", leaf->id, side); -} - -/* The split guide, in two stages, because a hover answers two questions. - * - * Anywhere on the side: the edge goes heavy and the handle thickens. That says - * *where to click*, which is the thing you cannot otherwise know now that the - * whole edge is no longer the button. - * - * On the handle: the dashed line and the arrow as well, which say *what will - * happen*. So sweeping a border shows you the button without also drawing a - * boundary nobody asked about, and the hint (`hint_for`) follows the same - * split -- it is the caption on the second stage, not the first. - * - * Drawn *after* the pane's content, because the dashed line crosses it: the - * first version painted under the terminal and was invisible. Hover only, so - * an idle frame stays quiet. */ -static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) { - if (!a->ptr_valid) return; - if (a->drag.kind == DRAG_TITLE || a->drag.kind == DRAG_SELECT || - a->drag.kind == DRAG_EDGE) - return; /* a drag in progress means the pointer is busy */ - - /* Arm on dwell, not on contact: a pointer merely crossing a border on its - * way somewhere else should not make the screen flash. Holding the button - * on a border is intent, so that skips the wait. */ - if (a->drag.kind != DRAG_BORDER && - now_ms_() - a->ptr_still_since < CFG.hover_delay_ms) - return; - - /* Ask the hit list where the pointer is, using the entries this very pass - * registered for this pane. Drawing and hit-testing cannot disagree, and - * neither can drawing and *the layout*. */ - const char *action = hit_test(&s->hits, a->ptr_x, a->ptr_y); - if (!action) return; - char side = 0; - bool on_handle = false; - char want[48]; - snprintf(want, sizeof want, "border:%u:", leaf->id); - if (strncmp(action, want, strlen(want)) == 0) { - side = action[strlen(want)]; - on_handle = true; - } else { - snprintf(want, sizeof want, "brim:%u:", leaf->id); - if (strncmp(action, want, strlen(want)) == 0) { - side = action[strlen(want)]; - } else { - /* The top row, whose handle wears the `title:` name because a press - * there can still become a drag. Matched with the terminator checked, or - * pane 1 would answer for pane 12. */ - snprintf(want, sizeof want, "title:%u", leaf->id); - size_t n = strlen(want); - if (strncmp(action, want, n) == 0 && (!action[n] || action[n] == ':')) { - side = 't'; - on_handle = action[n] == ':'; - } - } - } - if (!side) return; - - /* Nothing is offered that cannot be delivered: below the floor this pane - * would only collapse, so the border simply stops being a button. */ - if (!split_fits(leaf, side_dir(side))) return; - - rect_t r = leaf->rect; - if (r.w < 4 || r.h < 4) return; - uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1); - color_t hi = GUIDE; - - /* Stage one: the armed edge, on the cells the edge actually owns. - * - * A pane's name, its buttons and its scroll indicator all live on the top - * border, and the first version ruled straight through them: the row went - * unreadable exactly while the pointer was on it, and the handle looked as - * though it had been dropped at random, because the title it sits beside had - * been painted over. Asking the hit list which cells belong to this edge is - * the same question the handle already asks two lines down, and it costs one - * lookup per cell of one edge. - * - * The dead row's own buttons sit on the bottom border for the same reason, so - * this is not a top-row special case. */ - char rim[48], act[48]; - split_rim_action(leaf, side, rim, sizeof rim); - split_handle_action(leaf, side, act, sizeof act); - if (side == 'l' || side == 'r') { - uint16_t bx = side == 'l' ? r.x : x1; - for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { - const char *own = hit_test(&s->hits, bx, y); - if (own && (strcmp(own, rim) == 0 || strcmp(own, act) == 0)) - screen_text(s, bx, y, "\u2503", hi, NO_COLOR, ATTR_BOLD); - } - } else { - uint16_t by = side == 't' ? r.y : y1; - for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { - const char *own = hit_test(&s->hits, x, by); - if (own && (strcmp(own, rim) == 0 || strcmp(own, act) == 0)) - screen_text(s, x, by, "\u2501", hi, NO_COLOR, ATTR_BOLD); - } - } - - /* The handle, made heavier, and — where that means claiming a cell the pane - * was using — the hit for it registered here, because *this* is the frame in - * which it is that size. - * - * Only the upright sides grow. A cell is about twice as tall as it is wide - * (`gap_aspect`, and the split picker leans on the same fact), so one extra - * column is a nudge and one extra row is a slab: a two-row bar across a top or - * bottom border reads as a wall rather than as a thicker line, and eats a row - * of somebody's output to say so. The block glyph is already the heaviest - * thing a single cell can draw, and against `━` it is unmistakable, so a - * horizontal handle stays one row and says it with ink instead of size. - * - * The rect is read back from the list rather than recomputed: draw_frame - * placed it, and for the top row it places it wherever the title and the - * buttons left room, so recomputing it here would be a second opinion about - * where it is. Each cell is then painted only if the list still says it - * belongs to the handle, which is what keeps a block off the pane's name. - * Drawing asks the question the click is going to ask. */ - const hit_t *core = hit_find(&s->hits, act); - if (core) { - rect_t h = {core->x, core->y, core->w, core->h}; - if (side == 'l' || side == 'r') { - uint16_t in = side == 'l' ? (uint16_t)(h.x + 1) : (uint16_t)(h.x - 1); - hit_add(&s->hits, in, h.y, 1, h.h, act); - if (in < h.x) h.x = in; - h.w = 2; - } - /* The button carries its own verb. The arrow used to ride the dashed line, - * which put the two halves of the message in two places: the handle said - * *press here* and something over in the middle of the pane said *this is - * what happens*. On the handle it is one object that explains itself, and it - * is legible from the first stage -- brushing an edge now tells you which way - * that button splits without drawing a boundary you did not ask about. - * - * Knocked out of the bar with ATTR_INVERSE rather than drawn in a second - * colour: the block is solid `guide`, so a `guide` glyph on top of it would - * be invisible, and inverting spends no new theme knob to get contrast that - * follows whatever colour you set. - * - * Pointers rather than the matching triangles for left and right: U+25C0 and - * U+25B6 carry emoji presentation and terminals widely render them - * double-width, and screen_text books every chrome glyph as one cell — so a - * cell that draws as two would shift the rest of the row. U+25B2 is already - * the scroll indicator, so the vertical pair is known good here. */ - const char *arrow = side == 'l' ? "\u25c4" - : side == 'r' ? "\u25ba" - : side == 't' ? "\u25b2" - : "\u25bc"; - /* Along the handle it goes in the middle, which is where the pointer is - * aiming. Across it, on the edge's own cell rather than the one over the - * content: the arrow marks a boundary, and the boundary lands on the frame. - * That distinction only exists on the upright pair, since those are the two - * that grew a second cell. */ - bool vert = side == 'l' || side == 'r'; - uint16_t ax = vert ? (side == 'l' ? h.x : (uint16_t)(h.x + h.w - 1)) - : (uint16_t)(h.x + h.w / 2); - uint16_t ay = vert ? (uint16_t)(h.y + h.h / 2) : h.y; - for (uint16_t y = h.y; y < h.y + h.h; y++) { - for (uint16_t x = h.x; x < h.x + h.w; x++) { - const char *own = hit_test(&s->hits, x, y); - if (!own || strcmp(own, act) != 0) continue; - bool tip = x == ax && y == ay; - screen_text(s, x, y, tip ? arrow : "\u2588", hi, NO_COLOR, - tip ? (uint16_t)(ATTR_BOLD | ATTR_INVERSE) : ATTR_BOLD); - } - } - } - - if (!on_handle) return; - - /* Stage two: where the new boundary lands, and nothing about direction -- the - * handle is holding that end of the message now. A plain dashed line is also - * the honest shape for it: it is the *edge* of the new pane, and an edge has - * no middle worth marking. */ - if (side == 'l' || side == 'r') { - uint16_t mid = (uint16_t)(r.x + r.w / 2); - for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) - screen_text(s, mid, y, "\u254e", hi, NO_COLOR, 0); - } else { - uint16_t mid = (uint16_t)(r.y + r.h / 2); - for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) - screen_text(s, x, mid, "\u254c", hi, NO_COLOR, 0); - } -} - -static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { - rect_t r = leaf->rect; - if (r.w < 3 || r.h < 3) return; - bool focused = leaf == cur(a)->focus; - bool drop_target = a->drag.kind == DRAG_TITLE && a->drag.target == leaf->id && - a->drag.src != leaf->id; - color_t fg = drop_target ? DROP_C : (focused ? FRAME_FOCUS : FRAME_IDLE); - uint16_t attrs = drop_target ? ATTR_BOLD : 0; - - /* While a pane is being dragged, every other pane is somewhere it could be - * dropped, and a dashed border says so without needing a legend. The pane in - * your hand keeps a solid one, so the two states are told apart by the frame - * as well as by the colour — which matters on a terminal whose palette makes - * the greying subtle. The pane actually under the pointer still takes the - * drop_target highlight on top of the dashes: these are all targets, that is - * the one you are on. */ - bool drag_target = - a->drag.kind == DRAG_TITLE && a->drag.moved && a->drag.src != leaf->id; - const char *hbar = drag_target ? "\u2504" : "\u2500"; - const char *vbar = drag_target ? "\u2506" : "\u2502"; - - const char *tl = CFG.rounded ? "╭" : "┌", *tr = CFG.rounded ? "╮" : "┐"; - const char *bl = CFG.rounded ? "╰" : "└", *br = CFG.rounded ? "╯" : "┘"; - - uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1); - screen_text(s, r.x, r.y, tl, fg, NO_COLOR, attrs); - screen_text(s, x1, r.y, tr, fg, NO_COLOR, attrs); - screen_text(s, r.x, y1, bl, fg, NO_COLOR, attrs); - screen_text(s, x1, y1, br, fg, NO_COLOR, attrs); - for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { - screen_text(s, x, r.y, hbar, fg, NO_COLOR, attrs); - screen_text(s, x, y1, hbar, fg, NO_COLOR, attrs); - } - for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { - screen_text(s, r.x, y, vbar, fg, NO_COLOR, attrs); - screen_text(s, x1, y, vbar, fg, NO_COLOR, attrs); - } - - /* A pane that rang, marked just inside its corner: the same place on every - * pane, whatever its title is doing. */ - if (CFG.bell_indicator && pane_bell(leaf->pane) && r.w > 4) - screen_text(s, (uint16_t)(r.x + 1), r.y, CFG.bell_mark, BELL_C, NO_COLOR, - ATTR_BOLD); - - /* The frame's top row is the drag handle, all of it. Its *split* is a handle - * on the same row, but it is placed at the very end of this function rather - * than here: the title, the buttons and the scroll indicator all anchor - * wherever the config puts them, a centred title lands exactly where a - * centred handle would want to be, and this row's rule is that the title - * wins. So the handle is put where they are not, once they are all placed. */ - { - char action[48]; - snprintf(action, sizeof action, "title:%u", leaf->id); - hit_add(&s->hits, r.x, r.y, r.w, 1, action); - } - - /* No split button. The border *is* the button -- or the middle of it is: - * clicking a side's handle splits toward that side, which encodes the - * direction a single glyph never could, and gives every frame its columns - * back. See split_handle(). */ - uint16_t avail = (uint16_t)(r.w - 2); - bool has_btn = false; - uint16_t btn_x = x1; - /* Where the right-anchored group (buttons, then the scroll indicator) starts, - * and what the title took. Both are needed at the end of this function to - * find the top row's free cells; the corner counts as taken, hence x1. */ - uint16_t right_lo = x1; - bool has_title = false; - uint16_t title_lo = 0, title_hi = 0; - - /* The frame's own buttons, hard against the right of the top border. Three - * cells each: a one-cell target is a thing you miss with a mouse, and the - * spaces double as the gap between them. Budgeted before the scroll - * indicator, which then places itself to their left — the same right-first - * rule the tab strip and the status line use. */ - if (CFG.pane_buttons && avail > 10) { - struct { - const char *mark; - const char *verb; - } btns[] = { - {CFG.close_mark, "close"}, - {app_pane_zoomed(a, leaf->id) ? CFG.zoom_on_mark : CFG.zoom_mark, - "zoom"}, - {CFG.min_mark, "minimize"}, - }; - uint16_t bx = (uint16_t)(x1 - 1); - for (size_t i = 0; i < sizeof btns / sizeof *btns; i++) { - char cell[24]; - /* One space, not two. The gap between two marks is blank cells plus - * whatever blank each glyph carries inside its own cell, and that second - * part is not the same for a low underscore as for a square that fills - * its box. Two cells made the difference plain; one makes it small, and - * gives three columns of every frame back at the same time. */ - snprintf(cell, sizeof cell, "%s ", btns[i].mark); - /* Measured in cells rather than assumed: a mark is yours to choose and - * may be more than one character. Booking three cells for a - * two-character mark would draw it over its neighbour and hand the - * neighbour's hit a cell it does not own. */ - uint16_t mw = cells(btns[i].mark); - if (!mw) mw = 1; - uint16_t bw = (uint16_t)(mw + 1); - if (bx < r.x + 4 + bw) break; - uint16_t px = (uint16_t)(bx - bw + 1); - bool hot = ptr_on(a, px, r.y, bw, 1); - screen_text(s, px, r.y, cell, hot ? PANE_BTN_HOVER : PANE_BTN, NO_COLOR, - hot ? ATTR_BOLD : 0); - char action[48]; - snprintf(action, sizeof action, "%s:%u", btns[i].verb, leaf->id); - hit_add(&s->hits, px, r.y, bw, 1, action); - bx = (uint16_t)(px - 1); - has_btn = true; - btn_x = px; - avail = (uint16_t)(avail > bw ? avail - bw : 0); - } - /* One blank between the rule and the first button, so the group is not - * welded to the frame the way a title without its space would be. */ - if (has_btn && btn_x > (uint16_t)(r.x + 1)) { - screen_text(s, (uint16_t)(btn_x - 1), r.y, " ", PANE_BTN, NO_COLOR, 0); - btn_x = (uint16_t)(btn_x - 1); - avail = (uint16_t)(avail > 1 ? avail - 1 : 0); - } - if (has_btn) right_lo = btn_x; - } - - /* Each side twice over: `brim:` is the whole of it, which arms the guide on - * hover and does nothing at all on a click, and the handle on top of it is - * what splits. Registered in that order so the handle wins its own cells. - * - * The rim is a target rather than nothing so that hovering anywhere on an - * edge can still show you where the button is. Take it away and the handle - * becomes a thing you have to already know about. */ - { - char action[48]; - snprintf(action, sizeof action, "brim:%u:l", leaf->id); - hit_add(&s->hits, r.x, (uint16_t)(r.y + 1), 1, (uint16_t)(r.h - 2), action); - snprintf(action, sizeof action, "brim:%u:r", leaf->id); - hit_add(&s->hits, x1, (uint16_t)(r.y + 1), 1, (uint16_t)(r.h - 2), action); - snprintf(action, sizeof action, "brim:%u:b", leaf->id); - hit_add(&s->hits, r.x, y1, r.w, 1, action); - for (const char *side = "lrb"; *side; side++) { - rect_t h; - if (!split_handle(leaf, *side, &h)) continue; - split_handle_action(leaf, *side, action, sizeof action); - hit_add(&s->hits, h.x, h.y, h.w, h.h, action); - } - } - - draw_pane_status(a, s, leaf, fg, focused); - - /* Scroll position, when there is one: compact, right-aligned, and clickable - * to get back to the bottom. Budgeted after the button and before the title, - * because a title is the thing you can most afford to lose. */ - if (pane_scrolled(leaf->pane) && avail >= 8) { - uint32_t above = 0, total = 0; - pane_scroll_pos(leaf->pane, &above, &total); - /* A space between the arrow and the count. U+25B2 is drawn wide enough in - * plenty of fonts to touch whatever follows it, and "▲12" then reads as - * one smudged token rather than an arrow and a number. */ - char num[16]; - int nd = snprintf(num, sizeof num, "%u", total > above ? total - above : 0); - if (nd < 0) nd = 0; - char ind[24]; - snprintf(ind, sizeof ind, " \u25b2 %s ", num); - /* Counted in cells, not bytes: the arrow is three bytes and one column, - * and measuring it with strlen reserved two columns that were never used. */ - uint16_t iw = (uint16_t)(nd + 4); /* space, arrow, space, digits, space */ - if (iw + 2 < avail) { - uint16_t ix = (uint16_t)((has_btn ? btn_x : x1) - iw); - uint16_t drawn = - screen_text(s, ix, r.y, ind, SCROLL_FG, SCROLL_BG, ATTR_BOLD); - char action[48]; - snprintf(action, sizeof action, "scrollbottom:%u", leaf->id); - hit_add(&s->hits, ix, r.y, drawn, 1, action); - right_lo = ix; - avail = (uint16_t)(avail - iw); - } - } - - const char *title = pane_title(leaf->pane); - bool naming = a->renaming == RENAME_PANE && a->rename_id == leaf->id; - bool tagging = a->renaming == RENAME_PURPOSE && a->rename_id == leaf->id; - bool editing = naming || tagging; - if ((editing || (title && *title)) && avail >= 3) { - char buf[320]; /* a full-length name, plus the caret and its spaces */ - /* The purpose editor says which label it is: typed into the same cell as a - * rename, an unlabelled caret would leave you guessing which one you are - * changing -- and the two are edited from keys one shift apart. */ - int len = - tagging ? snprintf(buf, sizeof buf, " purpose %s\u2588 ", a->rename_buf) - : naming ? snprintf(buf, sizeof buf, " %s\u2588 ", a->rename_buf) - : snprintf(buf, sizeof buf, " %s ", title); - /* snprintf reports what it *would* have written; clamp to what it did, or - * the scroll below would move bytes that are not there. */ - if (len < 0) len = 0; - if ((size_t)len >= sizeof buf) len = (int)strlen(buf); - if (len > (int)avail) { - if (editing) { - /* Scroll the head off, not the tail: the cursor is the one thing that - * must stay on screen while typing. Whole characters only. */ - size_t off = (size_t)(len - (int)avail); - while (off < (size_t)len && ((unsigned char)buf[off] & 0xC0) == 0x80) - off++; - memmove(buf, buf + off, (size_t)len - off + 1); - len = (int)((size_t)len - off); - } else { - len = (int)avail; - buf[len] = 0; - } - } - /* The inset is an edge offset, so it applies to whichever edge the title - * is anchored to and means nothing in the middle. */ - uint16_t inset = CFG.title_inset < avail ? CFG.title_inset : 0; - uint16_t tx = (uint16_t)(r.x + 1 + inset); - if (CFG.title_align == ALIGN_CENTER) - tx = (uint16_t)(r.x + 1 + (avail - len) / 2); - else if (CFG.title_align == ALIGN_RIGHT) - tx = (uint16_t)(r.x + 1 + avail - len - inset); - /* An editor announces itself: the name sits in the button colours while it - * is being typed, so a half-finished rename can never be mistaken for what - * the pane is actually called. */ - uint16_t drawn = screen_text( - s, tx, r.y, buf, - editing ? RENAME_FG : (focused ? TITLE_FOCUS : TITLE_IDLE), - editing ? RENAME_BG : NO_COLOR, editing || focused ? ATTR_BOLD : 0); - - /* The title names the pane; it is not an edge. It carves its own cells out - * of the top row's handle so that resting there arms no split guide and - * clicking there splits nothing, which leaves the gesture free for a - * double-click rename — otherwise a rename would split twice on its way. - * Dragging still moves the pane: that reads as grabbing it by its name. - * - * Registered last, because hit_test() scans backwards and the title is - * painted last too. Hit-testing therefore agrees with what is on screen, - * and the width comes from screen_text() so a title of wide characters - * claims the cells it actually drew. */ - if (drawn) { - char action[48]; - snprintf(action, sizeof action, "panetitle:%u", leaf->id); - hit_add(&s->hits, tx, r.y, drawn, 1, action); - has_title = true; - title_lo = tx; - title_hi = (uint16_t)(tx + drawn - 1); - } - } - - /* The top row's split handle, last of all, in whatever the row has left. - * - * The other three sides put it in the middle, which is where a person looks - * for it. This row cannot promise that: the buttons own its right, and a - * centred title -- the default -- owns exactly the cells the middle would - * want. The title winning is the rule (see above: that is what keeps a - * double-click rename from splitting twice on its way), so the handle takes - * the widest run the row still owns and, between equals, the one nearest the - * middle. A row with nothing left offers no upward split, and its other three - * edges are unaffected. */ - { - uint16_t lo = (uint16_t)(r.x + 1); - if (CFG.bell_indicator && pane_bell(leaf->pane) && r.w > 4) lo++; - struct run { - uint16_t lo, hi; - } runs[2]; - size_t nruns = 0; - if (has_title) { - if (title_lo > lo) - runs[nruns++] = (struct run){lo, (uint16_t)(title_lo - 1)}; - if ((uint16_t)(title_hi + 1) < right_lo) - runs[nruns++] = - (struct run){(uint16_t)(title_hi + 1), (uint16_t)(right_lo - 1)}; - } else if (lo < right_lo) { - runs[nruns++] = (struct run){lo, (uint16_t)(right_lo - 1)}; - } - - /* As near the middle of the row as the row allows. - * - * Centred in the widest *run* was the first rule and it looked arbitrary, - * because it is: with a centred title the row leaves two runs of nearly the - * same size, one of them a cell longer for reasons no one can see, and the - * handle floated off into the middle of whichever won. Aiming at the row's - * middle and sliding into the run instead means the handle hugs whatever is - * in the way -- so it sits against the title, where the eye already is, - * rather than halfway to the corner. With nothing in the way it is simply - * centred, which is the same rule the other three sides follow. */ - uint16_t mid = (uint16_t)(r.x + r.w / 2); - uint16_t cap = split_handle_len((uint16_t)(r.w - 2)); - bool found = false; - uint16_t best_x = 0, best_w = 0, best_dist = 0; - for (size_t i = 0; i < nruns; i++) { - if (runs[i].hi < runs[i].lo) continue; - uint16_t len = (uint16_t)(runs[i].hi - runs[i].lo + 1); - if (len < 3) continue; - uint16_t want = cap > len ? len : cap; - uint16_t last = (uint16_t)(runs[i].hi - want + 1); - uint16_t start = mid > want / 2 ? (uint16_t)(mid - want / 2) : runs[i].lo; - if (start < runs[i].lo) start = runs[i].lo; - if (start > last) start = last; - uint16_t c = (uint16_t)(start + want / 2); - uint16_t dist = (uint16_t)(c > mid ? c - mid : mid - c); - /* Nearest wins; a tie goes to the longer handle, which can only happen - * when one run is too short to hold a full one. */ - if (!found || dist < best_dist || (dist == best_dist && want > best_w)) { - found = true; - best_x = start; - best_w = want; - best_dist = dist; - } - } - if (found) { - char action[48]; - split_handle_action(leaf, 't', action, sizeof action); - hit_add(&s->hits, best_x, r.y, best_w, 1, action); - } - } -} - -struct draw { - app_t *a; - screen_t *s; -}; - -/* Defined with the other shader passes below, because it is one. A collapsed - * row needs it here: the row is chrome, and it is the only thing a flattened - * tab draws of a pane. */ -static void shade_chrome(app_t *a, screen_t *s, node_t *n, rect_t r, - const rect_t *hole); - -/* A collapsed subtree: one row, the title of the pane it stands for, and a hit - * that focuses it — which expands it on the next layout pass, because the - * expanded child is simply the one holding focus. */ -static void draw_collapsed(app_t *a, screen_t *s, node_t *n) { - node_t *leaf = first_leaf_of(n); - if (!leaf) return; - rect_t r = n->rect; - if (r.w < 4) return; - - const char *title = pane_title(leaf->pane); - const char *status = pane_status(leaf->pane); - /* A pane that is not live says so here, in place of whatever its program - * last asked us to show — which is stale by definition once the program is - * gone or was never started. - * - * This row is the only thing a flattened tab draws of a pane, and the - * *content* shader pass deliberately never reaches it: those colour - * contents, and a header is chrome (D13). So the states that get a colour - * everywhere else have to be carried by the words here, in the same order - * the status line ranks them. Without this, collapsing a tab hides exactly - * the facts the colour exists to show. A `where="chrome"` chain does reach - * this row — it is a frame — but that is a thing you may have asked for, - * not something the words can rely on. */ - bool dead = !pane_alive(leaf->pane) && !pane_suspended(leaf->pane); - char words[64]; - if (dead) { - exit_words(leaf->pane, words, sizeof words); - status = words; - } else if (pane_suspended(leaf->pane)) { - status = "not started"; - } else if (pane_scrolled(leaf->pane)) { - uint32_t above = 0, total = 0; - pane_scroll_pos(leaf->pane, &above, &total); - snprintf(words, sizeof words, "\u25b2 %u", - total > above ? total - above : 0); - status = words; - } - char line[256]; - /* A space on each side, always. A title welded to the rule beside it reads - * as one longer word, and the rule is not part of the name. */ - snprintf(line, sizeof line, " %s%s%s ", title && *title ? title : "pane", - status && *status ? " · " : "", status && *status ? status : ""); - - /* Once a tab is a list, its rows are what you are picking from, and a row - * under the pointer should say so. Immediate rather than on dwell: this is - * feedback about where the pointer *is*, not an action being armed, and a - * list you are scanning should track the mouse continuously — the dwell that - * stops a guide from flashing would only make this feel broken. - * - * Tested against the rect registered as the hit two lines below, so the row - * that lights up and the row that would be clicked are the same row by - * construction. A drag already owns the pointer and says nothing here. */ - bool hot = ptr_on(a, r.x, r.y, r.w, 1); - - /* Foreground only. A filled bar is louder than the thing it is telling you, - * and this row has to sit in a list of its own kind without shouting. */ - color_t rule = hot ? HEADER_HOVER : HEADER; - color_t label = hot ? HEADER_HOVER_TITLE : (dead ? DEAD_C : HEADER); - - /* A collapsed pane draws the top edge of a pane, corners and all. It is not - * decoration: this row *is* a pane, closed — so a stack of them reads as a - * stack of panes rather than as a list of labels that happen to be above - * one. The open pane below wears the same corners. */ - const char *tl = CFG.rounded ? "╭" : "┌", *tr = CFG.rounded ? "╮" : "┐"; - uint16_t x1 = (uint16_t)(r.x + r.w - 1); - for (uint16_t x = r.x; x <= x1; x++) - screen_text(s, x, r.y, "─", rule, NO_COLOR, 0); - screen_text(s, r.x, r.y, tl, rule, NO_COLOR, 0); - screen_text(s, x1, r.y, tr, rule, NO_COLOR, 0); - - /* Just inside the corner, where it is in the same place on every pane - * whatever its title is doing. */ - if (CFG.bell_indicator && pane_bell(leaf->pane) && r.w > 4) - screen_text(s, (uint16_t)(r.x + 1), r.y, CFG.bell_mark, BELL_C, NO_COLOR, - ATTR_BOLD); - - uint16_t tx = (uint16_t)(r.x + 1 + CFG.title_inset); - if (tx < x1) { - size_t room = (size_t)(x1 - tx); /* never over the closing corner */ - if (strlen(line) > room) line[room] = 0; - screen_text(s, tx, r.y, line, label, NO_COLOR, hot ? ATTR_BOLD : 0); - } - - char action[48]; - snprintf(action, sizeof action, "focus:%u", leaf->id); - hit_add(&s->hits, r.x, r.y, r.w, 1, action); - - /* Last, so the pass sees the finished row: rule, bell, title and all. The - * state comes from the leaf this row stands for, not from the node that - * happens to own the rect — a collapsed subtree is drawn as its first pane - * and should be coloured as that pane. */ - shade_chrome(a, s, leaf, r, NULL); -} - -/* ---- shaders ------------------------------------------------------------ */ - -/* A chain the program in the pane asked for, in the config's own syntax so that - * what you prototype is what you can paste (D13, revisited). Replaces that - * chain: empty text clears it, which is how a program puts a pane back. - * - * Refused, with a reason, when `in_band_shaders` is off -- a program restyling - * itself by accident is exactly what that decision was about, and the answer is - * a line of config rather than a rule nobody can lift. */ -/* A preset file, applied to this pane: both chains at once, routed by each entry's - * own `where=`. The session reads the file, because the session has the parser -- - * a program that wants to prototype should not have to reimplement KDL, and a - * second reader of the format would be a second answer about what a file says. - * - * Gated like setting a chain by hand, and for the same reason: this is the program - * in the pane restyling the pane. Relative paths resolve against the *session's* - * working directory, which is why `contrib/shader-repl` sends an absolute one -- - * the pane's own cwd is the pane's business and the session cannot see it. */ -bool app_load_pane_shaders(app_t *a, uint32_t pane_id, const char *path, - size_t *nchrome, size_t *ncontent, char *err, - size_t errcap) { - if (nchrome) *nchrome = 0; - if (ncontent) *ncontent = 0; - if (err && errcap) err[0] = 0; - if (!CFG.in_band_shaders) { - if (err && errcap) snprintf(err, errcap, "in_band_shaders is off"); - return false; - } - node_t *n = pane_by_id(a, pane_id ? pane_id : app_focused_pane_id(a)); - if (!n) { - if (err && errcap) snprintf(err, errcap, "no such pane"); - return false; - } - if (!path || !path[0]) { - if (err && errcap) snprintf(err, errcap, "no path"); - return false; - } - - /* Both chains built beside the live ones and swapped in together, so a file - * that turns out to be unreadable half way through leaves the pane as it was - * rather than wearing the half that parsed. */ - inband_chain_t con = {0}, chr = {0}; - expr_prog_t *exprs[SHADE_MAX * 2]; - size_t nexprs = 0; - size_t got = - config_parse_chain_file(path, CFG.frame_focus, con.sh, &con.n, chr.sh, - &chr.n, exprs, &nexprs, err, errcap); - if (!got) { - for (size_t i = 0; i < nexprs; i++) expr_free(exprs[i]); - if (err && errcap && !err[0]) snprintf(err, errcap, "nothing in it to run"); - return false; - } - - /* The programs belong to whichever chain holds the shader that points at one. - * Walking the two chains rather than trusting the order they came back in: a - * chain that frees a program another chain is still using is the one bug in - * this that would not show up until the next repaint. */ - for (size_t i = 0; i < nexprs; i++) { - bool mine = false; - for (size_t j = 0; j < chr.n && !mine; j++) - if (chr.sh[j].amount_expr == exprs[i]) { - chr.exprs[chr.nexprs++] = exprs[i]; - mine = true; - } - for (size_t j = 0; j < con.n && !mine; j++) - if (con.sh[j].amount_expr == exprs[i]) { - con.exprs[con.nexprs++] = exprs[i]; - mine = true; - } - if (!mine) expr_free(exprs[i]); /* its entry was dropped */ - } - - chain_clear(&n->chrome_chain); - chain_clear(&n->content_chain); - n->chrome_chain = chr; - n->content_chain = con; - pane_touch(n->pane); - if (nchrome) *nchrome = chr.n; - if (ncontent) *ncontent = con.n; - return true; -} - -/* Everything this pane painted on itself, gone: both chains at once. - * - * Not gated on `in_band_shaders`, unlike setting one. A chain can outlive the - * consent that allowed it -- set it, then turn the setting off, and the paint is - * still there -- so the way out must not be the thing the setting controls. It is - * also the operator's answer to a pane that has made itself unreadable, and an - * answer you have to edit a config to reach is not one. - * - * Says nothing about the config's own chains or the session's policy passes: - * those are not this pane's doing and are derived every frame anyway. */ -bool app_clear_pane_shaders(app_t *a, uint32_t pane_id) { - node_t *n = pane_by_id(a, pane_id ? pane_id : app_focused_pane_id(a)); - if (!n) return false; - bool had = n->content_chain.n || n->chrome_chain.n; - chain_clear(&n->content_chain); - chain_clear(&n->chrome_chain); - if (had) pane_touch(n->pane); - return had; -} - -bool app_set_pane_shaders(app_t *a, uint32_t pane_id, bool default_chrome, - const char *text, size_t *nchrome, size_t *ncontent, - char *err, size_t errcap) { - if (nchrome) *nchrome = 0; - if (ncontent) *ncontent = 0; - if (err && errcap) err[0] = 0; - if (!CFG.in_band_shaders) { - if (err && errcap) snprintf(err, errcap, "in_band_shaders is off"); - return false; - } - node_t *n = pane_by_id(a, pane_id); - if (!n) { - if (err && errcap) snprintf(err, errcap, "no such pane"); - return false; - } - - /* Both chains, because the text is a *document*: `where=` inside it decides - * where each pass goes, exactly as it does in the config file this syntax comes - * from, and `default_chrome` is only what a pass that keeps quiet means. An - * earlier version refused an entry that named the other rect, which made the - * prompt's mode a rule rather than a default -- and made a two-rect block, the - * thing `:paste` prints, impossible to paste back. - * - * Parsed beside the live chains and swapped in together, so text that turns out - * to be unreadable leaves the pane as it was rather than half-restyled. */ - inband_chain_t con = {0}, chr = {0}; - expr_prog_t *exprs[SHADE_MAX * 2]; - size_t nexprs = 0; - config_parse_chain_doc(text, CFG.frame_focus, default_chrome, con.sh, &con.n, - chr.sh, &chr.n, exprs, &nexprs, err, errcap); - if (err && errcap && err[0]) { - for (size_t i = 0; i < nexprs; i++) expr_free(exprs[i]); - return false; - } - - /* Each program to the chain whose shader points at it. Walked rather than - * assumed: freeing one that the other chain still uses is the bug here that - * would not show up until the next repaint. */ - for (size_t i = 0; i < nexprs; i++) { - bool mine = false; - for (size_t j = 0; j < chr.n && !mine; j++) - if (chr.sh[j].amount_expr == exprs[i]) { - chr.exprs[chr.nexprs++] = exprs[i]; - mine = true; - } - for (size_t j = 0; j < con.n && !mine; j++) - if (con.sh[j].amount_expr == exprs[i]) { - con.exprs[con.nexprs++] = exprs[i]; - mine = true; - } - if (!mine) expr_free(exprs[i]); - } - - chain_clear(&n->chrome_chain); - chain_clear(&n->content_chain); - n->chrome_chain = chr; - n->content_chain = con; - pane_touch(n->pane); - if (nchrome) *nchrome = chr.n; - if (ncontent) *ncontent = con.n; - return true; -} - -/* The session's opinion about this pane at this moment, as a shader. - * - * Derived every frame rather than attached and remembered. Focus and drags - * move through too many paths — hover, click, the finder, a close, a layout — - * to keep an attachment in sync with, and a pane left grey by the one path - * that forgot to clear it is a bug that only shows up in front of someone. - * The rect and the collapsed flag are recomputed every pass for exactly this - * reason; this is the same rule applied to colour. */ -/* Which state this pane is in, or PSTATE_COUNT for none. - * - * A pane is usually in several at once, so the order here is the answer: the - * first that matches wins and the rest are not asked. It runs from the most - * transient and deliberate to the most ambient, because a pane you are holding - * should not be recoloured by anything, a mode the whole screen is in outranks - * a hint about one pane, and "not focused" is the weakest thing that can be - * true of a pane. */ -static pane_state_t pane_state_of(app_t *a, node_t *n) { - /* Only once the pointer has actually moved: a press that turns out to be a - * click would otherwise flash the whole session on its way to nothing. */ - if (a->drag.kind == DRAG_TITLE && a->drag.moved) { - if (n->id == a->drag.src) return PSTATE_DRAGGING; - if (n->id == a->drag.target) return PSTATE_DROP_HOVER; - return PSTATE_DROP_TARGET; - } - if (!pane_alive(n->pane) && !pane_suspended(n->pane)) return PSTATE_DEAD; - if (pane_suspended(n->pane)) return PSTATE_SUSPENDED; - /* A rung pane outranks a scrolled or unfocused one, and *replaces* it rather - * than stacking with it: states do not stack (D13), and of the things true - * of a pane that rang while you were elsewhere, the ringing is the one you - * needed telling. The focused pane never reaches here with a bell — looking - * at it answers the bell before anything is drawn. */ - if (pane_bell(n->pane)) return PSTATE_BELL; - if (pane_scrolled(n->pane)) return PSTATE_SCROLLED; - if (n != cur(a)->focus) return PSTATE_UNFOCUSED; - return PSTATE_COUNT; -} - -/* ...and when it last became that, which is the part a frame cannot tell you. - * Stamped here, in the one place that decides the state, so `since` cannot end - * up describing a state the pane is no longer in. Idempotent within a frame: - * the content pass and the chrome pass both ask, and the second sees no - * change. */ -static pane_state_t pane_state(app_t *a, node_t *n) { - pane_state_t st = pane_state_of(a, n); - if (st != n->last_state || !n->state_since) { - n->last_state = st; - n->state_since = now_ms_(); - } - return st; -} - -static size_t policy_shaders(app_t *a, node_t *n, shader_t *out, size_t cap) { - pane_state_t st = pane_state(a, n); - if (st >= PSTATE_COUNT) return 0; - size_t k = CFG.state_n[st]; - if (k > cap) k = cap; - for (size_t i = 0; i < k; i++) out[i] = CFG.state_shaders[st][i]; - return k; -} - -static size_t chrome_policy_shaders(app_t *a, node_t *n, shader_t *out, - size_t cap) { - pane_state_t st = pane_state(a, n); - if (st >= PSTATE_COUNT) return 0; - size_t k = CFG.chrome_state_n[st]; - if (k > cap) k = cap; - for (size_t i = 0; i < k; i++) out[i] = CFG.chrome_state_shaders[st][i]; - return k; -} - -/* Does anything in this chain read the clock? If so the session has to keep - * painting on its own, because the thing that will change next is the time. - * Asked of the chain that was actually applied rather than of the config: a - * pulse hung off `dead` costs a frame clock while a pane is dead and nothing - * for the rest of the session. */ -static void note_animation(app_t *a, const shader_t *chain, size_t n) { - for (size_t i = 0; i < n; i++) - if (chain[i].amount_expr && - (expr_deps(chain[i].amount_expr) & EXPR_DEP_TIME)) { - a->animating = true; - return; - } -} - -/* Scrollback as the viewport sees it, for content and chrome passes alike, so - * an expression can say "there is more this way": lines hidden above the top - * edge, and below the bottom. `total` from pane_scroll_pos is already the - * scrollable overhang (history minus the viewport), so the split needs no - * geometry of its own. */ -static void ctx_scroll(shade_ctx_t *base, const node_t *n) { - if (!n->pane) return; - uint32_t off = 0, total = 0; - pane_scroll_pos(n->pane, &off, &total); - base->above = off; - base->below = total > off ? total - off : 0; -} - -/* A pane with nothing to apply costs nothing: no context is built and no cell - * is visited, so an unshaded session emits the same bytes it did before. */ -static void shade_leaf(app_t *a, screen_t *s, node_t *n) { - shader_t chain[SHADE_CHAIN_MAX]; - size_t nc = 0; - - /* Configured first, then this pane's own, then policy last: what you asked - * every pane to look like, adjusted for this pane, and only then the - * session's opinion about this moment — which has to be able to grey out - * whatever the other two produced. */ - for (size_t i = 0; i < CFG.nshaders && nc < SHADE_CHAIN_MAX; i++) - chain[nc++] = CFG.shaders[i]; - for (size_t i = 0; i < n->content_chain.n && nc < SHADE_CHAIN_MAX; i++) - chain[nc++] = n->content_chain.sh[i]; - nc += policy_shaders(a, n, &chain[nc], SHADE_CHAIN_MAX - nc); - - if (!nc) return; - bool focused = n == cur(a)->focus; - /* After policy_shaders, which is what stamps the transition. */ - int64_t now = now_ms_(); - shade_ctx_t base = { - .now_ms = now, - .state_ms = n->state_since ? now - n->state_since : 0, - .focused = focused, - .default_fg = CFG.default_fg, - .default_bg = CFG.default_bg, - }; - ctx_scroll(&base, n); - /* The screen's cursor belongs to whichever pane is focused, so it is only - * this pane's cursor when this pane is the focused one. Handing it to any - * other pane would have its spotlight chasing a cursor somewhere else. */ - if (focused && s->cursor_visible && s->cursor_x >= n->content.x && - s->cursor_y >= n->content.y) { - base.has_cursor = true; - base.cursor_x = (uint16_t)(s->cursor_x - n->content.x); - base.cursor_y = (uint16_t)(s->cursor_y - n->content.y); - } - note_animation(a, chain, nc); - shade_apply(s, chain, nc, n->content, NULL, &base); -} - -/* Chrome: a pass over `r`, skipping `hole` (the contents, when the thing being - * drawn has any). One pass over the whole frame rather than one per side, so - * that an effect can travel round it — four passes would each count from zero - * and a sweep would restart at every corner. - * - * The rect is passed in rather than taken from the node because the two things - * that wear chrome are not the same shape: an open pane is its rect minus its - * contents, and a collapsed pane is one row that is chrome all the way through - * — its `content` is the size the program inside still believes it has, which - * is somewhere else entirely, and punching that out of the row would remove - * cells the row does not even overlap. `n` is only asked which state it is in, - * so a collapsed row is coloured by the pane it stands for. - * - * No cursor. The cursor is in the contents by construction, so a frame pass - * carrying one would hand an effect a position outside its own rect; `cursor` - * reads 0 here, which is the truth about a frame. */ -static void shade_chrome(app_t *a, screen_t *s, node_t *n, rect_t r, - const rect_t *hole) { - shader_t chain[SHADE_CHAIN_MAX]; - size_t nc = 0; - - /* Configured, then this pane's own, then policy -- the same order the - * contents use, and for the same reason: the session's opinion about this - * moment has to be able to grey out whatever the other two produced. */ - for (size_t i = 0; i < CFG.nchrome_shaders && nc < SHADE_CHAIN_MAX; i++) - chain[nc++] = CFG.chrome_shaders[i]; - for (size_t i = 0; i < n->chrome_chain.n && nc < SHADE_CHAIN_MAX; i++) - chain[nc++] = n->chrome_chain.sh[i]; - nc += chrome_policy_shaders(a, n, &chain[nc], SHADE_CHAIN_MAX - nc); - if (!nc) return; - - int64_t now = now_ms_(); - shade_ctx_t base = { - .now_ms = now, - .state_ms = n->state_since ? now - n->state_since : 0, - .focused = n == cur(a)->focus, - .default_fg = CFG.default_fg, - .default_bg = CFG.default_bg, - }; - ctx_scroll(&base, n); - note_animation(a, chain, nc); - shade_apply(s, chain, nc, r, hole, &base); -} - -/* Drawn after the panes, so the corner's hit is registered after the two gap - * hits it sits on and wins those cells: the cross is one target, not the - * overlap of two. */ -static void draw_corners(app_t *a, screen_t *s) { - for (size_t i = 0; i < a->ncorners; i++) { - corner_t *c = &a->corners[i]; - char action[48]; - snprintf(action, sizeof action, "corner:%zu", i); - hit_add(&s->hits, c->r.x, c->r.y, c->r.w, c->r.h, action); - - bool active = a->drag.kind == DRAG_EDGE && (a->drag.c_nh || a->drag.c_nv) && - a->drag.c_nh && c->nh && a->drag.c_h[0] == c->h_id[0] && - a->drag.c_hedge[0] == c->h_edge[0] && - a->drag.c_v[0] == c->v_id[0]; - /* (C) The mark appears the moment the pointer is on it, before the dwell - * that arms the two boundaries. A crossing is two cells wide and gives no - * other sign it is anything: something has to say it is there, and the - * ghost costs nothing if you were only passing through. */ - bool over = - a->drag.kind == DRAG_NONE && ptr_on(a, c->r.x, c->r.y, c->r.w, c->r.h); - if (!active && !over) continue; - bool armed = active || now_ms_() - a->ptr_still_since >= CFG.hover_delay_ms; - for (uint16_t x = c->r.x; x < c->r.x + c->r.w; x++) - screen_text(s, x, c->r.y, - armed ? (active ? "\u256c" : "\u253c") : "\u253c", RESIZE_C, - NO_COLOR, active ? ATTR_BOLD : 0); - } -} - -static void draw_node(app_t *a, screen_t *s, node_t *n); - -static void draw_cb(node_t *n, void *ud) { - struct draw *d = ud; - char action[48]; - - /* Painted in order, so the hit list resolves last-painted-wins: the frame - * focuses, the content forwards the mouse, the button splits. */ - snprintf(action, sizeof action, "focus:%u", n->id); - hit_add(&d->s->hits, n->rect.x, n->rect.y, n->rect.w, n->rect.h, action); - - snprintf(action, sizeof action, "pane:%u", n->id); - hit_add(&d->s->hits, n->content.x, n->content.y, n->content.w, n->content.h, - action); - - draw_frame(d->a, d->s, n); - if (pane_suspended(n->pane)) { - /* Falls through to the shader pass rather than returning: a pane that has - * not started is a state you can want to colour, and its label is the only - * content it has. */ - /* The pane exists, is laid out, and has run nothing. Say what it would. */ - const char *label = pane_label(n->pane); - char line[256]; - snprintf(line, sizeof line, "press a key to run: %s", - label && *label ? label : "shell"); - /* Truncate rather than vanish: a hint that only appears on wide panes is - * worse than a clipped one, and the pane may be narrow precisely because - * there are twelve of them. */ - size_t w = strlen(line); - if (w > n->content.w) { - w = n->content.w; - if (w > 1) { - line[w - 1] = 0; - line[w - 2] = - 0xe2; /* fall back to a plain dot rather than a cut UTF-8 */ - line[w - 2] = '.'; - } else { - line[w] = 0; - } - } - screen_text(d->s, (uint16_t)(n->content.x + (n->content.w - w) / 2), - (uint16_t)(n->content.y + n->content.h / 2), line, TITLE_IDLE, - NO_COLOR, 0); - } else { - pane_compose(n->pane, d->s, n->content.x, n->content.y, - n == cur(d->a)->focus); - } - /* Between the contents and the chrome that goes over them: the frame was - * painted before this and lies outside the content rect, and the split guide - * is painted after, so it stays legible on top of a shaded pane. - * - * The chrome pass runs here too, and for the same reason: the frame it - * recolours is already painted, and the guide, the resize hints and the - * corners are painted after it and stay in their own colours. An affordance - * is not decoration and must not be dimmed along with the thing it is - * offered on. */ - shade_leaf(d->a, d->s, n); - shade_chrome(d->a, d->s, n, n->rect, &n->content); - draw_split_guide(d->a, d->s, n); -} - -struct bellsearch { - bool found; -}; -static void bell_cb(node_t *n, void *ud) { - struct bellsearch *b = ud; - if (pane_bell(n->pane)) b->found = true; -} -static bool tab_has_bell(tab_t *t) { - struct bellsearch b = {false}; - walk(t->root, bell_cb, &b); - return b.found; -} - -static void draw_tab_strip(app_t *a, screen_t *s) { - /* A pane is being carried: the strip is a row of destinations for as long as - * that is true. Worked out once rather than per tab. */ - bool dragging_pane = a->drag.kind == DRAG_TITLE && a->drag.moved; - uint16_t x = CFG.status_pad; - uint16_t y = CFG.gap; - - /* Right side first, so a long tab list can never eat the indicators — the - * same budgeting rule as the split button and the OSC buttons. */ - uint16_t right = - (uint16_t)(s->cols > CFG.status_pad ? s->cols - CFG.status_pad : s->cols); - char info[64]; - size_t np = app_pane_count(a); - snprintf(info, sizeof info, "%zu pane%s", np, np == 1 ? "" : "s"); - uint16_t iw = (uint16_t)strlen(info); - if (right > iw + 4) { - screen_text(s, (uint16_t)(right - iw), y, info, TAB_COUNT, NO_COLOR, 0); - right = (uint16_t)(right - iw - 1); - } - if (a->prefix) { /* the prefix is a mode: say so, and say which key */ - /* Rendered from the binding rather than written out, because it was - * written out: the badge said "C-a" whatever you had configured, which is - * the one place a rebound prefix could still lie to you. Measured too -- - * a prefix is not always three columns wide (`C-space`, `M-\``). */ - char pfx[24]; - config_chord_name(CFG.prefix_key, CFG.prefix_mods, pfx, sizeof pfx); - uint16_t pw = cells(pfx); - if (right > pw + 2) { - screen_text(s, (uint16_t)(right - pw), y, pfx, PREFIX_FG, PREFIX_BG, - ATTR_BOLD); - right = (uint16_t)(right - pw - 1); - } - } - - for (size_t i = 0; i < a->ntabs && x < right; i++) { - tab_t *t = &a->tabs[i]; - char label[80]; - const char *nm = t->name[0] ? t->name : (t->purpose[0] ? t->purpose : ""); - /* A pane that rang in a tab you are not looking at is invisible without - * this, and that is the case the whole indicator exists for. */ - bool rang = CFG.bell_indicator && tab_has_bell(t); - if (nm[0]) - snprintf(label, sizeof label, " %zu:%s ", i + 1, nm); - else - snprintf(label, sizeof label, " %zu ", i + 1); - - /* Renaming: the tab's own cell becomes the editor, in the editor's - * colours, so a half-typed name can never be mistaken for the tab's real - * one. The caret is part of the label, so the width below — and therefore - * the hit — is the width of what is actually drawn. */ - bool editing = a->renaming == RENAME_TAB && a->rename_id == t->id; - if (editing) snprintf(label, sizeof label, " %s\u2588 ", a->rename_buf); - - bool active = i == a->cur; - uint16_t attrs = active ? ATTR_BOLD : 0; - /* Two independent signals: weight says which tab you are in, colour says - * where the pointer is. Drawn once to learn the width, then again in the - * hover colour if that width turns out to be under the pointer — which - * costs a repaint of a few cells and guarantees the lit cells are the - * registered ones. */ - uint16_t w = - screen_text(s, x, y, label, - editing ? RENAME_FG : (active ? TAB_ACTIVE_FG : TAB_IDLE), - editing ? RENAME_BG : (active ? TAB_ACTIVE_BG : NO_COLOR), - editing ? ATTR_BOLD : attrs); - /* Hovering keeps the active tab's fill — it is still the tab you are in — - * so its feedback lands on the text instead. An inactive tab has no fill - * to keep, and brightens. */ - /* The bell keeps its own colour here too, rather than taking the tab's — - * an indicator drawn in the same dim grey as the label it sits next to is - * an indicator you have to already be looking for. */ - if (rang) { - char mark[24]; - snprintf(mark, sizeof mark, "%s ", CFG.bell_mark); - w = (uint16_t)(w + screen_text(s, (uint16_t)(x + w), y, mark, BELL_C, - active ? TAB_ACTIVE_BG : NO_COLOR, - ATTR_BOLD)); - } - if (!editing && ptr_on(a, x, y, w, 1)) - screen_text(s, x, y, label, active ? TAB_ACTIVE_HOVER_FG : TAB_HOVER, - active ? TAB_ACTIVE_BG : NO_COLOR, attrs | ATTR_BOLD); - - /* While a pane is in your hand, every tab it does not already live in is - * somewhere it could go, and `ptr_on` says nothing during a drag by design -- - * so the strip has to draw the drop states itself. Same two states the panes - * use: all the candidates in the drop colour, and the one under the pointer - * filled, because these are all targets and that is the one you are on. */ - if (dragging_pane) { - node_t *held = pane_by_id(a, a->drag.src); - bool mine = held && tab_of(a, held) == i; - if (!mine) { - bool on = a->drag.tab_target == t->id; - screen_text(s, x, y, label, on ? TAB_ACTIVE_HOVER_FG : DROP_C, - on ? DROP_C : NO_COLOR, attrs | ATTR_BOLD); - } - } - char action[48]; - snprintf(action, sizeof action, "tab:%u", t->id); - hit_add(&s->hits, x, y, w, 1, action); - x = (uint16_t)(x + w); - } - - /* A bare mark, spaced like the frame's own buttons rather than spelled out. - * It used to read "+tab", because a pane frame carried a "+" for splitting - * and two verbs that look identical is a UI bug the fork shipped. That "+" - * went when the border became the button, so the collision it was avoiding - * no longer exists and the word was left explaining itself to nobody. - * - * Padded to three cells for the same reason the frame's buttons are: a - * one-cell target is a thing you miss with a mouse. What it does is said by - * the hint under the pointer, which is where every other one-character - * affordance here says it. */ - { - char btn[24]; - snprintf(btn, sizeof btn, " %s ", CFG.newtab_mark); - uint16_t bw = (uint16_t)(cells(CFG.newtab_mark) + 2); - if (bw > 2 && x + bw <= right) { - uint16_t w = screen_text(s, x, y, btn, TAB_IDLE, NO_COLOR, 0); - if (ptr_on(a, x, y, w, 1)) - screen_text(s, x, y, btn, TAB_HOVER, NO_COLOR, ATTR_BOLD); - /* The button that makes a tab is also a place to drop a pane into one. */ - if (dragging_pane) - screen_text(s, x, y, btn, - a->drag.new_tab_target ? TAB_ACTIVE_HOVER_FG : DROP_C, - a->drag.new_tab_target ? DROP_C : NO_COLOR, ATTR_BOLD); - hit_add(&s->hits, x, y, w, 1, "newtab"); - } - } -} - -/* The space between two of a split's children, or false if they are flush. - * One implementation, because the drawing, the hit and the corner-finding all - * have to agree about where a gap is down to the cell. */ -static bool gap_rect(node_t *n, size_t i, rect_t *out) { - if (!n || n->kind != NODE_SPLIT || i + 1 >= n->nkids) return false; - rect_t a_r = n->kids[i]->rect, b_r = n->kids[i + 1]->rect; - if (!a_r.w || !b_r.w) return false; - if (n->dir == SPLIT_COLS) { - uint16_t x0 = (uint16_t)(a_r.x + a_r.w); - if (b_r.x <= x0) return false; - *out = (rect_t){x0, a_r.y, (uint16_t)(b_r.x - x0), a_r.h}; - } else { - uint16_t y0 = (uint16_t)(a_r.y + a_r.h); - if (b_r.y <= y0) return false; - *out = (rect_t){a_r.x, y0, a_r.w, (uint16_t)(b_r.y - y0)}; - } - return true; -} - -struct gapinfo { - node_t *sp; - size_t i; - rect_t r; -}; - -static size_t collect_gaps(node_t *n, struct gapinfo *out, size_t cap, - size_t k) { - if (!n || n->kind != NODE_SPLIT || k >= cap) return k; - for (size_t i = 0; i + 1 < n->nkids && k < cap; i++) { - rect_t g; - if (!gap_rect(n, i, &g)) continue; - out[k].sp = n; - out[k].i = i; - out[k].r = g; - k++; - } - for (size_t i = 0; i < n->nkids; i++) - k = collect_gaps(n->kids[i], out, cap, k); - return k; -} - -/* Where a boundary between rows crosses a boundary between columns. - * - * The two never overlap in the tree — a column boundary stops where the row - * boundary begins, because they belong to different splits — so a corner is - * found by adjacency rather than by intersection: the cells of the row gap - * that have a column gap running into them from above or below. - * - * Both are collected when both are there. In a two-by-two the column boundary - * above and the one below are separate splits that happen to line up, and - * moving one without the other would leave a step in what reads as one line. */ -static void find_corners(app_t *a) { - a->ncorners = 0; - if (!a->ntabs || !cur(a)->root) return; - - struct gapinfo g[64]; - size_t n = collect_gaps(cur(a)->root, g, 64, 0); - - /* Every place a column boundary meets a row boundary. - * - * They never overlap: whichever way the tree is nested, one of them stops - * where the other begins. Which one stops depends on the nesting — rows of - * columns give a full-width row boundary with column boundaries running into - * it from above and below, columns of rows give a full-height column - * boundary with row boundaries running into it from the sides — so the test - * is that the two touch at all, in either axis, rather than one particular - * arrangement of them. - * - * The crossing is always the column boundary's columns by the row - * boundary's rows, and boundaries are grouped by the crossing they land on. - * Two that line up are one crossing that moves both, because moving one of a - * matched pair would put a step in what reads as a single line; two that do - * not are separate crossings. */ - for (size_t i = 0; i < n; i++) { - if (g[i].sp->dir != SPLIT_ROWS) continue; - rect_t h = g[i].r; - for (size_t j = 0; j < n; j++) { - if (g[j].sp->dir != SPLIT_COLS) continue; - rect_t v = g[j].r; - - bool touch_y = - v.y <= (uint16_t)(h.y + h.h) && h.y <= (uint16_t)(v.y + v.h); - bool touch_x = - h.x <= (uint16_t)(v.x + v.w) && v.x <= (uint16_t)(h.x + h.w); - if (!touch_y || !touch_x) continue; - - rect_t cr = {v.x, h.y, v.w, h.h}; - corner_t *c = NULL; - for (size_t k = 0; k < a->ncorners; k++) { - rect_t e = a->corners[k].r; - if (e.x == cr.x && e.y == cr.y && e.w == cr.w && e.h == cr.h) { - c = &a->corners[k]; - break; - } - } - if (!c) { - if (a->ncorners >= 16) break; - c = &a->corners[a->ncorners++]; - *c = (corner_t){0}; - c->r = cr; - } - - bool have = false; - for (size_t k = 0; k < c->nh; k++) - if (c->h_id[k] == g[i].sp->id && c->h_edge[k] == g[i].i) have = true; - if (!have && c->nh < 2) { - c->h_id[c->nh] = g[i].sp->id; - c->h_edge[c->nh] = g[i].i; - c->nh++; - } - have = false; - for (size_t k = 0; k < c->nv; k++) - if (c->v_id[k] == g[j].sp->id && c->v_edge[k] == g[j].i) have = true; - if (!have && c->nv < 2) { - c->v_id[c->nv] = g[j].sp->id; - c->v_edge[c->nv] = g[j].i; - c->nv++; - } - } - } -} - -static int corner_at(app_t *a, uint16_t x, uint16_t y) { - for (size_t i = 0; i < a->ncorners; i++) { - rect_t r = a->corners[i].r; - if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return (int)i; - } - return -1; -} - -static bool corner_uses(const corner_t *c, uint32_t id, size_t edge) { - for (size_t i = 0; i < c->nh; i++) - if (c->h_id[i] == id && c->h_edge[i] == edge) return true; - for (size_t i = 0; i < c->nv; i++) - if (c->v_id[i] == id && c->v_edge[i] == edge) return true; - return false; -} - -/* The gap between two panes is a handle, and nothing about two blank columns - * says so. So it says it on hover — and says something else once you have hold - * of it, because "you could move this" and "you are moving this" are different - * claims and the second one should not have to be inferred from the panes - * changing size. - * - * Dotted while available, doubled and bold while engaged: the same grammar the - * split guide already uses, where dashes are a possibility and weight is a - * commitment. */ -static void draw_resize_hint(app_t *a, screen_t *s, node_t *split, size_t idx, - rect_t gapr) { - bool active = a->drag.kind == DRAG_EDGE && a->drag.src == split->id && - a->drag.edge == idx; - /* A corner moves two boundaries, so resting on it arms both of them: the - * hint has to show what is about to move, not where the pointer is. */ - int ci = -1; - if (a->drag.kind == DRAG_EDGE && (a->drag.c_nv || a->drag.c_nh)) { - for (size_t k = 0; k < a->drag.c_nh; k++) - if (a->drag.c_h[k] == split->id && a->drag.c_hedge[k] == idx) - active = true; - for (size_t k = 0; k < a->drag.c_nv; k++) - if (a->drag.c_v[k] == split->id && a->drag.c_vedge[k] == idx) - active = true; - } else if (a->drag.kind == DRAG_NONE) { - ci = corner_at(a, a->ptr_x, a->ptr_y); - } - if (ci >= 0 && (size_t)ci < a->ncorners && - corner_uses(&a->corners[ci], split->id, idx)) { - if (false) - active = true; - else if (now_ms_() - a->ptr_still_since >= CFG.hover_delay_ms) { - uint16_t attrs0 = 0; - if (split->dir == SPLIT_COLS) { - uint16_t x = (uint16_t)(gapr.x + gapr.w / 2); - for (uint16_t y = gapr.y; y < gapr.y + gapr.h; y++) - screen_text(s, x, y, "\u250a", RESIZE_C, NO_COLOR, attrs0); - } else { - uint16_t y = (uint16_t)(gapr.y + gapr.h / 2); - for (uint16_t x = gapr.x; x < gapr.x + gapr.w; x++) - screen_text(s, x, y, "\u2508", RESIZE_C, NO_COLOR, attrs0); - } - return; - } - } - if (!active) { - /* A pointer busy with anything else is not shopping for a boundary. */ - if (a->drag.kind != DRAG_NONE || !a->ptr_valid) return; - /* Tested against the very rect just registered as this gap's hit, in the - * same loop iteration — so the hint cannot appear anywhere the click would - * not land, without having to trust a lookup to agree. */ - if (a->ptr_x < gapr.x || a->ptr_x >= gapr.x + gapr.w || a->ptr_y < gapr.y || - a->ptr_y >= gapr.y + gapr.h) - return; - /* Arm on dwell, like the split guide: crossing a gap on the way to a pane - * is the most ordinary mouse movement there is. */ - if (now_ms_() - a->ptr_still_since < CFG.hover_delay_ms) return; - } - - uint16_t attrs = active ? ATTR_BOLD : 0; - - /* An arrow in the middle of the line, naming the verb: the dots say this is - * a handle, the arrow says what pulling it does. It points along the axis the - * boundary *moves*, not the one it lies on — a divider between two columns is - * drawn vertically and travels sideways, and the useful half of that is the - * travelling. Always bold: it is the one cell meant to be read. - * - * Double arrows rather than U+2194/U+2195, which carry emoji presentation and - * are widely drawn double-width; screen_text books a chrome cell as one, so a - * glyph that drew as two would shift the row. They also happen to rhyme with - * the doubled line the active state uses. */ - if (split->dir == SPLIT_COLS) { - uint16_t x = (uint16_t)(gapr.x + gapr.w / 2); - for (uint16_t y = gapr.y; y < gapr.y + gapr.h; y++) - screen_text(s, x, y, active ? "\u2551" : "\u250a", RESIZE_C, NO_COLOR, - attrs); - screen_text(s, x, (uint16_t)(gapr.y + gapr.h / 2), "\u21d4", RESIZE_C, - NO_COLOR, ATTR_BOLD); - } else { - uint16_t y = (uint16_t)(gapr.y + gapr.h / 2); - for (uint16_t x = gapr.x; x < gapr.x + gapr.w; x++) - screen_text(s, x, y, active ? "\u2550" : "\u2508", RESIZE_C, NO_COLOR, - attrs); - screen_text(s, (uint16_t)(gapr.x + gapr.w / 2), y, "\u21d5", RESIZE_C, - NO_COLOR, ATTR_BOLD); - } -} - -static void draw_node(app_t *a, screen_t *s, node_t *n) { - if (n->collapsed) { - draw_collapsed(a, s, n); - return; - } - if (n->kind == NODE_LEAF) { - struct draw d = {a, s}; - draw_cb(n, &d); - return; - } - - /* The gap between two children is the boundary you can drag. It is drawn as - * nothing, but it is a real target, derived from the rects the children were - * just given rather than recomputed from the config. */ - for (size_t i = 0; i + 1 < n->nkids; i++) { - rect_t gapr; - if (!gap_rect(n, i, &gapr)) continue; - char action[48]; - snprintf(action, sizeof action, "edge:%u:%zu", n->id, i); - hit_add(&s->hits, gapr.x, gapr.y, gapr.w, gapr.h, action); - draw_resize_hint(a, s, n, i, gapr); - } - - for (size_t i = 0; i < n->nkids; i++) draw_node(a, s, n->kids[i]); -} - -/* ---- 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. */ -static 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. */ -static 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); -} - -static 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. */ -static 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. */ -static 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. */ -static 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. */ -static 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); -} - -static 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); -} - -static 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. */ -static 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 */ -} - -/* ---- kitty graphics ------------------------------------------------------ */ - -struct gfx_ctx { - app_t *a; - node_t *leaf; -}; - -static void gfx_from_pane(pane_t *p, const pane_gfx_t *g, void *ud) { - struct gfx_ctx *c = ud; - node_t *leaf = c->leaf; - - /* Pane-local viewport cells become screen cells, and anything hanging over - * the pane's edge is cropped by asking for fewer columns and rows — the - * only clipping the protocol lets us do without knowing the client's cell - * size in pixels. */ - if (g->col >= leaf->content.w || g->row >= leaf->content.h) return; - uint16_t cols = g->cols, rows = g->rows; - uint32_t sw = g->sw, sh = g->sh; - /* Cropping means moving the source rectangle, and how much source a lost - * cell is worth depends on whether the image is being scaled: - * - * natural size one source pixel is one screen pixel, so the crop is the - * screen pixels the pane has left -- measured from where - * the image starts, which is `x_off` into its first cell - * and not at the cell's edge. Cropping by whole cells alone - * let an offset image hang up to a cell over the border. - * scaled the program asked for c=/r=, so a cell is worth - * source/cells pixels and the crop is proportional. Taking - * screen pixels off a scaled image would crop far too much - * -- a 4px image across 40 cells loses its whole self. */ - if (g->col + cols > leaf->content.w) { - uint16_t avail = (uint16_t)(leaf->content.w - g->col); - if (g->req_cols) { - sw = cols ? (uint32_t)((uint64_t)sw * avail / cols) : sw; - } else { - uint32_t px = (uint32_t)avail * g->cell_px_w; - px = px > g->x_off ? px - g->x_off : 0; - if (sw > px) sw = px; - } - cols = avail; - } - if (g->row + rows > leaf->content.h) { - uint16_t avail = (uint16_t)(leaf->content.h - g->row); - if (g->req_rows) { - sh = rows ? (uint32_t)((uint64_t)sh * avail / rows) : sh; - } else { - uint32_t px = (uint32_t)avail * g->cell_px_h; - px = px > g->y_off ? px - g->y_off : 0; - if (sh > px) sh = px; - } - rows = avail; - } - if (!cols || !rows) return; - - gfx_place( - c->a->gfx, - &(gfx_req_t){ - .pane = leaf->id, - .src_id = g->image_id, - .gen = g->generation, - .place_id = g->place_id, - .col = (uint16_t)(leaf->content.x + g->col), - .row = (uint16_t)(leaf->content.y + g->row), - .cols = cols, - .rows = rows, - /* Carried through untouched: the pane's own clipping already dealt with - * it, and the offset is relative to the first cell either way. */ - .x_off = g->x_off, - .y_off = g->y_off, - /* Clipped the same way the cell counts were, and zero when the program - * never asked to scale. */ - .scale_cols = g->req_cols ? cols : 0, - .scale_rows = g->req_rows ? rows : 0, - .sx = g->sx, - .sy = g->sy, - .sw = sw, - .sh = sh, - .px_w = g->src_w, - .px_h = g->src_h, - .format = g->format, - .compression = g->compression, - .data = g->data, - .data_len = g->data_len, - }); -} - -static void gfx_leaf_cb(node_t *n, void *ud) { - app_t *a = ud; - if (n->hidden) return; /* a collapsed pane draws nothing, images included */ - struct gfx_ctx ctx = {a, n}; - pane_graphics(n->pane, gfx_from_pane, &ctx); -} - -/* Walk the visible tab's panes and produce the bytes the client's terminal - * needs this frame. Borrowed until the next call. */ -const char *app_graphics(app_t *a, size_t *len) { - gfx_begin(a->gfx); - if (a->ntabs && cur(a)->root) walk(cur(a)->root, gfx_leaf_cb, a); - return gfx_flush(a->gfx, len); -} - -void app_graphics_reset(app_t *a) { gfx_reset(a->gfx); } - -char *app_graphics_json(app_t *a) { - size_t len = 0; - app_graphics(a, &len); /* refresh the model; the bytes are the caller's job */ - const gfx_place_t *places = NULL; - size_t n = gfx_placements(a->gfx, &places); - json_t j; - json_init(&j); - json_arr_open(&j, NULL); - for (size_t i = 0; i < n; i++) { - json_obj_open(&j, NULL); - json_int(&j, "image", places[i].out_id); - json_int(&j, "placement", places[i].place_id); - json_int(&j, "x", places[i].col); - json_int(&j, "y", places[i].row); - json_int(&j, "cols", places[i].cols); - json_int(&j, "rows", places[i].rows); - /* Reported so a test can see sub-cell motion, which is otherwise only - * visible as "the picture moves smoothly" on somebody's screen. */ - json_int(&j, "x_off", places[i].x_off); - json_int(&j, "y_off", places[i].y_off); - json_obj_close(&j); - } - json_arr_close(&j); - return j.buf; -} - /* ---- input -------------------------------------------------------------- */ static node_t *by_id(app_t *a, uint32_t id) { @@ -6261,7 +1160,7 @@ static bool prefix_command(app_t *a, const input_event_t *ev) { /* What an action does, given the action rather than the key that asked for it * -- because the command palette asks for actions by name, and a palette that * had to synthesise a keystroke to run one would be inventing input. */ -static bool run_action(app_t *a, action_t act) { +bool run_action(app_t *a, action_t act) { if (act >= ACT_SELECT_TAB_1) { app_select_tab(a, (size_t)(act - ACT_SELECT_TAB_1)); return true; @@ -6682,209 +1581,6 @@ void app_event(app_t *a, const input_event_t *ev) { } } -/* ---- dumping a session back out as a layout ------------------------------ - * - * The inverse of apply-layout, and the thing that makes a restart survivable: - * build a fresh binary, dump what you have, quit, come back with `--layout`. - * contrib/slosh-dev wraps exactly that. - * - * What can honestly be restored is the *shape*: tabs, their names and - * purposes, how the panes are split, in what proportion, in which directory, - * running what they were started with. What cannot is the state inside a - * program -- a shell's history, a running vim -- and this does not pretend - * otherwise. A pane running the session's default shell is dumped as a pane - * with no command, so it comes back as a shell rather than as a re-run of one. - */ - -typedef struct { - char *buf; - size_t len, cap; -} strbuf_t; - -static void sb_add(strbuf_t *b, const char *fmt, ...) { - va_list ap; - for (;;) { - va_start(ap, fmt); - int n = vsnprintf(b->buf + b->len, b->cap - b->len, fmt, ap); - va_end(ap); - if (n < 0) return; - if ((size_t)n < b->cap - b->len) { - b->len += (size_t)n; - return; - } - b->cap = b->cap ? b->cap * 2 : 1024; - while (b->cap - b->len <= (size_t)n) b->cap *= 2; - b->buf = realloc(b->buf, b->cap); - } -} - -/* KDL strings are double-quoted, so the two characters that end or escape one - * have to be escaped themselves. A path can contain both. */ -static void sb_quoted(strbuf_t *b, const char *key, const char *val) { - if (!val || !*val) return; - sb_add(b, " %s=\"", key); - for (const char *p = val; *p; p++) { - if (*p == '"' || *p == '\\') - sb_add(b, "\\%c", *p); - else if ((unsigned char)*p >= 0x20) - sb_add(b, "%c", *p); - } - sb_add(b, "\""); -} - -/* Where the program in the pane is *now*, which after any amount of `cd` is - * not where it was started. The kernel knows; nothing else does. */ -static const char *live_cwd(const pane_t *p, char *buf, size_t cap) { - pid_t pid = pane_pid(p); - if (pid > 0) { - char link[64]; - snprintf(link, sizeof link, "/proc/%d/cwd", (int)pid); - ssize_t n = readlink(link, buf, cap - 1); - if (n > 0) { - buf[n] = 0; - return buf; - } - } - return pane_start_cwd(p); /* not running, or not Linux: what it was given */ -} - -/* Everything one dump needs to know, so dump_node does not have to ask the app - * which tab it is in the middle of. The focused pane belongs to *its* tab, not - * to the one you happen to be looking at -- reading `cur(a)->focus` here meant - * a dump of three tabs restored the focus of one. */ -typedef struct { - const tab_t *tab; - const char *base; - int suspend; - size_t panes, suspended; -} dumpctx_t; - -/* The command this pane is written back out with, or NULL for a plain shell. - * - * Two sources, in this order. The **label** is what a layout told this pane to - * run, and it outranks everything: it survives the program exiting (D14), and - * re-saving a project must not degrade `npm run dev` into whatever node's argv - * happens to look like this minute. Failing that, **what the pane's terminal is - * actually running** -- because a pane you split and typed a command into had - * nothing to say for itself and came back as a bare shell, which made setting a - * project up by hand and writing it down two different jobs instead of one. - * - * An ephemeral pane is a task that happened to be open when the dump was taken, - * and is written with no command either way: restoring it would reopen somebody's - * editor on a file they finished with. */ -static const char *dump_command(const node_t *n, char *buf, size_t cap) { - if (pane_ephemeral(n->pane)) return NULL; - const char *label = pane_label(n->pane); - if (label && *label) return label; - return pane_foreground(n->pane, buf, cap); -} - -/* Whether this pane is written as one that has not started yet. - * - * `as-is` is the honest answer for a session dump: what is suspended now is - * suspended in the file. It is the wrong answer for a project's layout, where - * the pane running the dev server you started this morning would start one on - * every open -- which is the thing `suspended` exists to prevent. So a saved - * project defaults to `commands`: a pane that has a command is written asleep, a - * shell is not. Same distinction, and the same word, as `keep_dead`. - * - * "Has a command" is whatever the file is about to say, captured or declared, so - * the two cannot disagree -- a pane written with a `command=` and no - * `suspended=true` under this policy would start a dev server on every open. */ -static bool dump_suspended(const char *command, const node_t *n, int policy) { - switch (policy) { - case DUMP_SUSPEND_NONE: return false; - case DUMP_SUSPEND_ALL: return true; - case DUMP_SUSPEND_COMMANDS: return command && *command; - default: return pane_suspended(n->pane); - } -} - -/* `split=` belongs on the node that *has* the children -- that is where - * build_pane() reads it -- and a tab's own props are the props of its root, - * so a root split says so on the tab. Getting this backwards produces a file - * that loads without complaint and rebuilds the wrong tree. */ -static void dump_node(node_t *n, strbuf_t *b, int depth, dumpctx_t *ctx) { - char pad[64]; - int p = depth * 4 < 60 ? depth * 4 : 60; - memset(pad, ' ', (size_t)p); - pad[p] = 0; - - sb_add(b, "%spane", pad); - /* A weight is a share of the parent, so the root of a tab has none. */ - if (n->parent) sb_add(b, " weight=%d", n->weight); - - if (n->kind == NODE_SPLIT) { - sb_add(b, " split=\"%s\" {\n", n->dir == SPLIT_ROWS ? "rows" : "cols"); - for (size_t i = 0; i < n->nkids; i++) - dump_node(n->kids[i], b, depth + 1, ctx); - sb_add(b, "%s}\n", pad); - return; - } - - ctx->panes++; - char cwdbuf[4096]; - /* Relative to the project when there is one, so the file is the same file on - * another machine. A directory outside the base stays absolute: it is not a - * fact about the project. */ - sb_quoted(b, "cwd", - path_relative(live_cwd(n->pane, cwdbuf, sizeof cwdbuf), ctx->base)); - char cmdbuf[4096]; - const char *command = dump_command(n, cmdbuf, sizeof cmdbuf); - sb_quoted(b, "command", command); - sb_quoted(b, "purpose", n->purpose); - if (dump_suspended(command, n, ctx->suspend)) { - sb_add(b, " suspended=true"); - ctx->suspended++; - } - if (n == ctx->tab->focus) sb_add(b, " focus=true"); - sb_add(b, "\n"); -} - -char *app_dump_layout(app_t *a, dump_layout_t *o) { - dump_layout_t all = {0}; - if (!o) o = &all; - o->tabs = o->panes = o->suspended = 0; - - strbuf_t b = {0}; - sb_add(&b, "layout {\n"); /* no timestamp: a layout that differs every time - * is a bad diff */ - for (size_t i = 0; i < a->ntabs; i++) { - tab_t *t = &a->tabs[i]; - if (o->tab && t->id != o->tab) continue; - dumpctx_t ctx = {.tab = t, .base = o->base, .suspend = o->suspend}; - sb_add(&b, " tab"); - sb_quoted(&b, "name", t->name); - /* The workspace's own purpose is derived from where the project is, so a - * file that lives there does not repeat it -- and a copy of the project - * elsewhere becomes its own workspace rather than claiming this one's. */ - if (!(o->for_project && strncmp(t->purpose, "project:", 8) == 0)) - sb_quoted(&b, "purpose", t->purpose); - /* Which tab you were looking at is a fact about a session. Asked for one - * tab, the answer is that tab, and `active` would be noise in a file - * checked in beside a project. */ - if (!o->tab && i == a->cur) sb_add(&b, " active=true"); - /* The tab's props are its root's props, so a root that is a split says - * which way it goes here rather than on a `pane` node of its own. */ - if (t->root && t->root->kind == NODE_SPLIT) - sb_add(&b, " split=\"%s\"", t->root->dir == SPLIT_ROWS ? "rows" : "cols"); - sb_add(&b, " {\n"); - if (t->root) { - if (t->root->kind == NODE_SPLIT) - for (size_t k = 0; k < t->root->nkids; k++) - dump_node(t->root->kids[k], &b, 2, &ctx); - else - dump_node(t->root, &b, 2, &ctx); - } - sb_add(&b, " }\n"); - o->tabs++; - o->panes += ctx.panes; - o->suspended += ctx.suspended; - } - sb_add(&b, "}\n"); - return b.buf; -} - /* ---- introspection ------------------------------------------------------ */ struct panes_json { |
