aboutsummaryrefslogtreecommitdiffstats
path: root/include
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-19 13:46:57 +0200
committeruser <user@clank>2026-08-19 13:46:57 +0200
commit42b3c10995eb67778d9dd3aa2fd80624896acd5f (patch)
tree751bcb8604967dc8687cf23fbeb1033aa26ca427 /include
parentslosh: a website that is a page, not a mock session (diff)
downloadslosh-42b3c10995eb67778d9dd3aa2fd80624896acd5f.tar.gz
slosh-42b3c10995eb67778d9dd3aa2fd80624896acd5f.tar.xz
slosh-42b3c10995eb67778d9dd3aa2fd80624896acd5f.zip
slosh: defaults most people can live in, said by shaders
The out-of-the-box look, rethought as one piece: - the compiled-in palette calms down: muted blue #7aa2f7 on the same neutral darks, with the colours that mean something kept semantic -- amber bell, red-orange dead. sl0p stays one include away as the house style, and contrib/themes/default.kdl is the starting point written down (theme-tour shows it). - derived state defaults now actually follow the theme: the scrolled wash is rebuilt from scroll_bg after the theme block (it used to bake the compiled accent), and declaring a state -- in any file of an include chain -- still wins. The dump writes derived states as comments so C-a e cannot pin what is meant to keep following. - a bell you can see: the frame blinks three times in theme's bell colour then breathes until answered, and a soft diagonal shimmer -- the bell colour halved towards default_fg, width breathing on a sine -- crosses the body for the same 900ms. bell_indicator false silences all of it; states { bell { } } replaces it like any state. since carries the clock dependency, so an idle session pays nothing. - scrollback says where it continues: while scrolled, the viewport's top and bottom rows melt towards an edge with content past it and turn solid at the buffer's end. New expression variables above/below (lines hidden past each edge, own dependency bit, cached per scroll position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4. - expressions survive the dump: a program keeps its source and cb_chain writes it back, so amount="(y % 2) * 40" no longer dumps as a number. - headers move to include/, -Iinclude everywhere; the plugin Makefile's SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every pane it runs, and building from inside a session read that as a path. - comment drift culled where features outran the prose, and the derivation boilerplate folded into derived_pass()/ctx_scroll().
Diffstat (limited to 'include')
-rw-r--r--include/app.h322
-rw-r--r--include/cmd.h13
-rw-r--r--include/config.h510
-rw-r--r--include/expr.h102
-rw-r--r--include/graphics.h88
-rw-r--r--include/input.h94
-rw-r--r--include/json.h28
-rw-r--r--include/jsonval.h45
-rw-r--r--include/kdl.h50
-rw-r--r--include/osc5577.h56
-rw-r--r--include/png.h22
-rw-r--r--include/project.h64
-rw-r--r--include/proto.h68
-rw-r--r--include/server.h28
-rw-r--r--include/shader.h83
-rw-r--r--include/shader_abi.h181
-rw-r--r--include/slosh.h282
17 files changed, 2036 insertions, 0 deletions
diff --git a/include/app.h b/include/app.h
new file mode 100644
index 0000000..2a3435e
--- /dev/null
+++ b/include/app.h
@@ -0,0 +1,322 @@
+/* The session: a layout tree of panes, focus, and the chrome around them.
+ *
+ * Both front ends (the interactive client and the headless driver) drive this
+ * same object, so there is exactly one implementation of what a key does. */
+#ifndef SLOSH_APP_H
+#define SLOSH_APP_H
+
+#include "kdl.h"
+#include "project.h"
+#include "shader.h"
+#include "slosh.h"
+
+typedef enum { SPLIT_COLS, SPLIT_ROWS } split_dir_t;
+
+typedef struct node node_t;
+typedef struct app app_t;
+
+app_t *app_new(const char *const argv[], uint16_t cols, uint16_t rows);
+void app_free(app_t *a);
+
+void app_event(app_t *a, const input_event_t *ev);
+/* Kitty graphics for this frame: the bytes the client's terminal needs, and
+ * the same thing as JSON for tests. Bytes are borrowed until the next call. */
+const char *app_graphics(app_t *a, size_t *len);
+void app_graphics_reset(app_t *a);
+char *app_graphics_json(app_t *a);
+
+/* Transient announcements, drawn bottom-right and expiring on their own. */
+void app_toast(app_t *a, const char *text);
+size_t app_toast_count(app_t *a);
+/* Milliseconds until something needs repainting on its own — a toast expiring,
+ * a hover guide arming, or a shader whose amount reads the clock having run
+ * over the frame just composed — or -1 when nothing does. A property of that
+ * frame, so it is asked after composing one. */
+int app_next_deadline_ms(app_t *a);
+
+/* Text the session has copied, and the copy the client has not been told
+ * about yet (which the front end sends on as OSC 52). Caller frees the take. */
+const char *app_clipboard(const app_t *a);
+char *app_take_clipboard(app_t *a);
+
+/* Re-read the config file. Keeps the working one if the new file is broken. */
+bool app_reload_config(char *err, size_t errcap);
+/* The files the config in force was read from: the one that was loaded and
+ * everything it included, existing or not. What the watcher watches. */
+size_t app_config_files(const char **out, size_t max);
+/* 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 --
+ * so the session is running either way; this is for saying so. */
+const char *app_config_complaint(void);
+void app_resize(app_t *a, uint16_t cols, uint16_t rows);
+/* The attached client's cell size in pixels, passed on to every pane's pty and
+ * terminal. Images need it: a placement that does not say how many cells it
+ * covers is sized from the image's pixels and this. Ignored if either is 0,
+ * so a client that cannot find out leaves the default standing. */
+void app_set_cell_px(app_t *a, uint16_t w, uint16_t h);
+void app_cell_px(const app_t *a, uint16_t *w, uint16_t *h);
+
+/* Pump every pane that has data. Returns true if anything changed. */
+bool app_pump_fd(app_t *a, int fd);
+/* Fill `out` with every live pane fd; returns the count. */
+size_t app_fds(app_t *a, int *out, size_t max);
+/* Close panes whose process exited, collapsing the tree — only under
+ * `keep_dead false`. By default a dead pane stays until it is dismissed, so
+ * this does nothing and the pane offers [re-run] and [close] in its frame. */
+void app_reap(app_t *a);
+
+void app_compose(app_t *a, screen_t *s);
+/* Bytes straight into the focused pane's pty (the harness's `raw`). */
+void app_write_focused(app_t *a, const void *buf, size_t len);
+bool app_should_quit(const app_t *a);
+/* C-a d asks the client to leave while the session keeps running. */
+bool app_detach_requested(const app_t *a);
+void app_clear_detach(app_t *a);
+size_t app_pane_count(const app_t *a);
+
+/* Tabs. A tab is a layout tree; panes in every tab keep running. */
+uint32_t app_new_tab(app_t *a, const char *name);
+bool app_select_tab(app_t *a, size_t index);
+bool app_select_tab_id(app_t *a, uint32_t id);
+void app_cycle_tab(app_t *a, int delta);
+size_t app_tab_count(const app_t *a);
+bool app_close_tab(app_t *a, uint32_t id);
+bool app_set_tab_name(app_t *a, uint32_t id, const char *name);
+/* A pane's name, which outranks the title its program sets. 0 is the focused
+ * pane; an empty name clears it and hands the label back to the program. */
+bool app_set_pane_name(app_t *a, uint32_t id, const char *name);
+/* Put a tab at a different position in the strip, 0-based. The strip is the
+ * order, so this is what dragging one does; you stay in the tab you were in. */
+bool app_move_tab(app_t *a, uint32_t id, size_t index);
+
+/* Purposes (D8). `declared` means "from a layout/control API": it outranks an
+ * in-band purpose and locks it, so a pane cannot relabel itself afterwards --
+ * except by being cleared, which unlocks the slot and hands the label back,
+ * because a lock over an empty string is a state nothing can escape.
+ * `id` 0 is the focused pane and the current tab, as everywhere else.
+ * Returns false when a locked purpose refuses an in-band change. */
+bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose,
+ bool declared);
+bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose,
+ bool declared);
+
+/* Build tabs and panes from a KDL layout. `replace` drops what was there.
+ * Purposes a layout declares are locked (D8). A relative `cwd=` in a *file*
+ * resolves against that file's directory, so a project's layout can be checked
+ * in beside the project; text has no directory and keeps meaning what it said. */
+bool app_apply_layout_text(app_t *a, const char *text, bool replace, char *err,
+ size_t errcap);
+bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err,
+ size_t errcap);
+/* The same, with an explicit base -- for a layout whose text came from one place
+ * and whose relative paths mean another: a shared project layout in ~/.config
+ * opening a project in ~/dev. */
+bool app_apply_layout_text_at(app_t *a, const char *text, bool replace,
+ const char *base, char *err, size_t errcap);
+
+/* What a session would not honour in a layout document, one problem per entry,
+ * `file:line: text` -- the same shape `--check` prints for a config, because a
+ * project's layout is a file people edit by hand and `cmd=` where `command=`
+ * was meant is silently a shell. Returns how many it filled in; `dropped` is
+ * how many more there were, so a summary can say so. Neither allocates. */
+#define LAYOUT_MSGS_MAX 32
+typedef char layout_msg_t[192];
+size_t layout_check(const kdl_node_t *root, const char *file,
+ layout_msg_t *msgs, size_t max, size_t *dropped);
+size_t layout_check_file(const char *path, layout_msg_t *msgs, size_t max,
+ size_t *dropped);
+
+/* ---- workspaces ---------------------------------------------------------- *
+ *
+ * A project is a directory (project.h). A workspace is the tab it occupies in
+ * this session, and membership is that tab's `purpose` in the `project:`
+ * namespace -- 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 applying one already locks them (D8).
+ */
+
+/* Whether the config names anywhere to look. False means the feature is dormant
+ * and every verb below says so rather than answering with nothing. */
+bool app_project_roots_set(void);
+/* One project by name, or by a path under a root. False when no root holds it,
+ * which is also the containment check: a path this session will not write to. */
+bool app_workspace_find(const char *name, project_t *out);
+/* Every project the configured roots hold, sorted by name. */
+size_t app_projects(project_t *out, size_t max);
+/* The tab holding a workspace, or 0. */
+uint32_t app_workspace_tab(app_t *a, const char *slug);
+
+typedef struct {
+ uint32_t tab; /* the tab to land in */
+ char purpose[64]; /* the workspace's identity */
+ char path[512]; /* the project on disk */
+ size_t tabs; /* tabs it adopted */
+ size_t honoured; /* tabs whose own declared purpose was left alone */
+ bool created; /* false when it was already open and was focused */
+} app_workspace_open_t;
+
+/* Open a project's layout as a workspace, or focus the one already open --
+ * idempotent, so the same request twice is one workspace and a caller never has
+ * to ask first. `name` is a project name or a path under a root. `suspended`
+ * starts every pane asleep whatever the layout said, which is the "open ten
+ * projects, run zero processes" case. */
+bool app_workspace_open(app_t *a, const char *name, bool suspended,
+ app_workspace_open_t *out, char *err, size_t errcap);
+
+/* Close every tab carrying this workspace's purpose; returns how many. */
+size_t app_workspace_close(app_t *a, const char *slug);
+
+typedef struct {
+ char path[512]; /* the file written */
+ char purpose[64]; /* the workspace the tab now carries */
+ size_t panes, suspended;
+ bool replaced; /* there was a layout there before */
+} app_workspace_save_t;
+
+/* Write one tab out as a project's layout: the same dump `dump-layout` answers
+ * with, relative to the project, into `slosh.layout.kdl` beside it. `tab` 0 is
+ * the current one; `path` names the project for a tab that is not yet a
+ * workspace (and saving adopts it, which is the whole of onboarding one).
+ * Refused without `force` when the project already has a layout: that file is
+ * checked in. */
+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);
+
+/* What this session is called, for the status line. The server knows it
+ * because it opened the socket under that name. */
+void app_set_session(app_t *a, const char *name);
+
+/* Put a pane away into the strip along the bottom. 0 means the focused one.
+ * False if it would leave the tab with nothing on screen. Restoring is done by
+ * focusing it, which is what clicking its row does. */
+bool app_minimize(app_t *a, uint32_t id);
+
+/* Move a pane into another tab, beside whatever that tab has focused. 0 means the
+ * focused pane; `rows` puts it under rather than beside. The destination is a tab
+ * *id*, not an index: emptying the source tab removes it and shifts every index
+ * after it, which an id survives.
+ *
+ * The pane keeps running -- nothing is re-spawned, which is the point of moving one
+ * rather than opening another and closing this. False when there is nothing to do:
+ * no such pane, no such tab, or it is already there. Which tab you are looking at
+ * does not change.
+ *
+ * A move drops what the old tab thought about the pane: a zoom naming it, and its
+ * minimised flag. Carried across, the first would zoom a pane that has left and the
+ * second would file the arrival in a strip nobody asked for. */
+bool app_move_pane_to_tab(app_t *a, uint32_t pane_id, uint32_t tab_id,
+ bool rows);
+
+/* The same, into a tab of its own; returns that tab's id, or 0 -- including for a
+ * pane that is already alone in its tab, which has nowhere to go. */
+uint32_t app_move_pane_to_new_tab(app_t *a, uint32_t pane_id, const char *name);
+
+/* Fill the tab with one pane, or put it back. 0 means the focused one. */
+bool app_toggle_zoom(app_t *a, uint32_t id);
+bool app_pane_zoomed(app_t *a, uint32_t id);
+
+/* Give every visible pane in the current tab an even share of the rows and
+ * columns it competes for: each split's children weighted by how many visible
+ * panes are behind them, so "even" means the same thing at every depth. False
+ * when the tab is a single pane and there is nothing to divide. */
+bool app_equalize_splits(app_t *a);
+
+/* Address panes by id (the control API), not "the focused one". */
+bool app_focus_pane(app_t *a, uint32_t id);
+bool app_split_pane(app_t *a, uint32_t id, bool rows);
+bool app_close_pane(app_t *a, uint32_t id);
+/* Run a dead (or not-yet-started) pane's command again, in the same pane and
+ * on top of the same scrollback. 0 means the focused one. */
+bool app_rerun_pane(app_t *a, uint32_t id);
+
+/* Turn the current tab's layout a quarter turn clockwise: every split changes
+ * axis, and the children of a row split reverse, because the pane on top of a
+ * stack is the pane on the right once you turn it. Four turns are the identity.
+ * False when the tab is a single pane, which looks the same from every angle. */
+bool app_rotate_layout(app_t *a);
+/* Open the config file in $EDITOR, in a pane of its own, so the edit/save/
+ * watch loop happens without leaving the session. The pane is ephemeral. */
+bool app_edit_config(app_t *a);
+uint32_t app_focused_pane_id(app_t *a);
+uint32_t app_current_tab_id(app_t *a);
+
+/* Colour passes over one pane (shader.h), set by the program running in it over
+ * OSC 5577. `text` is a *document* in the config's own syntax -- one entry, several
+ * separated by `;`, or a whole `shaders { }` block -- and each pass goes where its
+ * own `where=` says, so both chains are set from one call. `default_chrome` is what
+ * a pass that does not say means, which is how a prompt aimed at the frame works
+ * without every line repeating itself. Empty text clears both.
+ *
+ * Counts come back so a caller can say what happened: "ok" alone cannot tell a
+ * block that filled both chains from one whose entries were all dropped. Refused
+ * with a reason in `err`: `in_band_shaders` off, no such pane, or text that does
+ * not read. */
+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);
+
+/* A preset file -- `contrib/chrome/sine-comet.kdl` and its neighbours -- applied
+ * to one pane, both chains at once, routed by each entry's own `where=`. The
+ * session reads the file so that nothing else has to know this format; a relative
+ * path resolves against the session's working directory. Counts come back so a
+ * caller can say what happened. Gated on `in_band_shaders` like setting a chain by
+ * hand: it is the same act with a shorter spelling. */
+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);
+
+/* Undo both of one pane's own chains. 0 means the focused pane. False when there
+ * was nothing to undo, so a caller can say so rather than flash a frame nobody
+ * changed.
+ *
+ * Deliberately not gated on `in_band_shaders`: a chain can outlive the consent
+ * that allowed it, and the way out must not be the thing the setting controls.
+ * Leaves the config's chains and the session's policy passes alone -- neither is
+ * this pane's doing. */
+bool app_clear_pane_shaders(app_t *a, uint32_t pane_id);
+
+/* What to write when a session is written back out. `tab` 0 is every tab;
+ * `base` relativises every `cwd=` under it, which is what makes a project's
+ * layout the same file on another machine; `suspend` decides which panes are
+ * written as not-yet-started. `panes` and `suspended` come back filled in, so
+ * a caller can say what it wrote. NULL means all of it, verbatim, absolute. */
+typedef enum {
+ DUMP_SUSPEND_ASIS = 0, /* what is suspended now */
+ DUMP_SUSPEND_NONE,
+ DUMP_SUSPEND_COMMANDS, /* every pane that was given a command */
+ DUMP_SUSPEND_ALL
+} dump_suspend_t;
+
+typedef struct {
+ uint32_t tab;
+ const char *base;
+ int suspend;
+ /* This dump *is* a project's own layout file, so leave out what the project's
+ * location already says: a tab's `project:` purpose is derived from the path,
+ * and a checked-in file carrying one would hand another checkout of the same
+ * repo the original's identity. Every other purpose is somebody's label and is
+ * written as usual. */
+ bool for_project;
+ /* out. `tabs` is how a caller tells "that tab has no panes" from "there is no
+ * such tab", which are the same empty document. */
+ size_t tabs, panes, suspended;
+} dump_layout_t;
+
+/* The session written back out as a layout file: tabs, splits, proportions,
+ * directories and commands. The inverse of app_apply_layout_text(), and what
+ * `dump-layout` and `save-workspace` both answer with -- one of them writes it
+ * to a file, which is the only difference between them.
+ *
+ * What a dump can honestly restore is the shape. Not the state inside a
+ * program: a shell's history, a running editor. A pane running the session's
+ * default shell is dumped as a pane with no command, so restoring it gives a
+ * fresh one, and a pane whose program has exited is dumped as what it ran. It
+ * does not pretend otherwise. */
+char *app_dump_layout(app_t *a, dump_layout_t *o);
+
+/* State as JSON, for the control API and the harness. Caller frees. */
+char *app_panes_json(app_t *a);
+char *app_tabs_json(app_t *a);
+
+#endif /* SLOSH_APP_H */
diff --git a/include/cmd.h b/include/cmd.h
new file mode 100644
index 0000000..a54505d
--- /dev/null
+++ b/include/cmd.h
@@ -0,0 +1,13 @@
+/* The command vocabulary, shared by the headless driver and the control
+ * socket, so a script written against one works against the other. */
+#ifndef SLOSH_CMD_H
+#define SLOSH_CMD_H
+
+#include "app.h"
+
+/* Execute one command line. Returns a freshly allocated reply (possibly an
+ * empty string), or NULL if the command is unknown. Sets *quit for "quit". */
+char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
+ bool *quit);
+
+#endif /* SLOSH_CMD_H */
diff --git a/include/config.h b/include/config.h
new file mode 100644
index 0000000..ce4d5b0
--- /dev/null
+++ b/include/config.h
@@ -0,0 +1,510 @@
+/* The opinionated defaults, and the file that overrides them.
+ *
+ * Everything here has a working value compiled in, so a missing or broken
+ * config file costs you a warning, never a terminal (the fail-open property
+ * D9 asks of the CLI, applied to the mux itself).
+ */
+#ifndef SLOSH_CONFIG_H
+#define SLOSH_CONFIG_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include "expr.h"
+#include "shader.h"
+#include "slosh.h"
+
+/* Pane states a shader can be hung off, in the order they are tested. Only
+ * states a pane can actually be seen in — which `dead` now is: a pane whose
+ * program exits is kept until it is dismissed, so there is a frame to draw.
+ * It ranks just under the drag states, above every ambient one: nothing about
+ * a pane is more worth knowing than that it is over. */
+typedef enum {
+ PSTATE_DRAGGING, /* the pane you have hold of */
+ PSTATE_DROP_HOVER, /* the one under the pointer, where it would land */
+ PSTATE_DROP_TARGET, /* the others, all of them somewhere it could go */
+ PSTATE_DEAD, /* its program exited; waiting to be re-run or closed */
+ PSTATE_SUSPENDED, /* laid out, never started */
+ /* A BEL arrived and nobody has looked since. Above the ambient states
+ * because it is *news* — the whole point of a bell is to be noticed on a
+ * pane you were not watching — and below `dead`, because a pane that rang
+ * and then exited is a pane that exited. It ends when you look at the pane,
+ * which is what answering a bell is. */
+ PSTATE_BELL,
+ PSTATE_SCROLLED, /* looking at scrollback rather than the present */
+ PSTATE_UNFOCUSED,
+ PSTATE_COUNT,
+} pane_state_t;
+
+/* The config name for a state, e.g. "drop_target". */
+const char *pane_state_name(pane_state_t s);
+
+typedef enum {
+ ACT_NONE = 0,
+ ACT_SPLIT,
+ ACT_SPLIT_COLS,
+ ACT_SPLIT_ROWS,
+ ACT_CLOSE_PANE,
+ ACT_RERUN,
+ ACT_ZOOM,
+ ACT_MINIMIZE,
+ ACT_SET_PURPOSE,
+ ACT_ROTATE_LAYOUT,
+ ACT_FOCUS_LEFT,
+ ACT_FOCUS_RIGHT,
+ ACT_FOCUS_UP,
+ ACT_FOCUS_DOWN,
+ ACT_FOCUS_NEXT,
+ ACT_NEW_TAB,
+ ACT_CLOSE_TAB,
+ ACT_NEXT_TAB,
+ ACT_PREV_TAB,
+ ACT_FINDER,
+ ACT_PALETTE,
+ ACT_WORKSPACES,
+ ACT_SAVE_WORKSPACE,
+ ACT_SCROLL_UP,
+ ACT_SCROLL_DOWN,
+ ACT_SCROLL_PAGE_UP,
+ ACT_SCROLL_PAGE_DOWN,
+ ACT_SCROLL_TOP,
+ ACT_SCROLL_BOTTOM,
+ ACT_RESIZE_LEFT,
+ ACT_RESIZE_RIGHT,
+ ACT_RESIZE_UP,
+ ACT_RESIZE_DOWN,
+ ACT_EQUALIZE,
+ ACT_CLEAR_SHADERS,
+ ACT_PANE_TO_NEXT_TAB,
+ ACT_PANE_TO_PREV_TAB,
+ ACT_PANE_TO_NEW_TAB,
+ ACT_DETACH,
+ ACT_QUIT,
+ ACT_HELP,
+ ACT_EDIT_CONFIG,
+ ACT_LITERAL_PREFIX,
+ ACT_SELECT_TAB_1, /* .. +8, so digits stay one entry each */
+} action_t;
+
+typedef struct {
+ int key; /* GhosttyKey */
+ uint16_t mods; /* MOD_* */
+ action_t action;
+ /* Fires without the leader, taking that chord away from every program in
+ * every pane. Deliberately possible and deliberately opt-in: it is the
+ * user's terminal and their keyboard, and a multiplexer that refuses to get
+ * out of the way is its own kind of wrong. */
+ bool direct;
+} binding_t;
+
+typedef enum { ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT } align_t;
+
+/* How many files one config may be built from: itself plus its includes. Small
+ * and fixed, because a config assembled from more than this many pieces is a
+ * thing nobody can read. */
+#define CONFIG_FILES_MAX 16
+
+/* How many project roots one config may name. `~/dev` and `~/work` is the case;
+ * a person with more than this many places they keep code has a search problem
+ * rather than a list problem. */
+#define PROJECT_ROOTS_MAX 8
+
+/* How many complaints one load keeps. A config with more than this many
+ * problems in it has one problem, and the list is long enough to say so. */
+#define CONFIG_MSGS_MAX 32
+
+typedef struct {
+ /* geometry */
+ uint16_t gap, gap_aspect;
+ /* Space between a pane's frame and its contents, per side, in the same unit
+ * `gap` uses: ROWS. A horizontal one is multiplied by `gap_aspect` on the way
+ * out, so `padding 1` is a square-looking ring rather than a squashed one —
+ * and so a number means the same thing wherever it appears, however many of
+ * them were written. Written as 1, 2 or 4 values, CSS order. */
+ uint16_t pad_top, pad_right, pad_bottom, pad_left;
+ bool rounded;
+ align_t title_align;
+ /* Cells between a frame's corner and the start of its title. The title
+ * always carries a space on each side on top of this, so a title can never
+ * end up welded to the rule beside it. */
+ uint16_t title_inset;
+ /* Mark a pane's titlebar when a BEL arrives there and nobody has looked
+ * since. Off makes a bell silent *and* invisible, which is a real choice. */
+ /* Buttons in the top-right of a pane's frame: zoom, and close. Off leaves
+ * the keyboard bindings alone; it is about the affordance, not the verb. */
+ /* A word in the middle of the status line for whatever the pointer is on.
+ * Discoverability for a frame whose affordances are mostly one character
+ * wide; off if you already know them. */
+ bool hints;
+ /* What slosh this is, in the middle of the status line, whenever there is
+ * no hint to put there. The slot is otherwise empty most of the time, and
+ * "which build am I attached to" is the first question when a session is
+ * behaving oddly -- a session keeps the binary it started with, so the
+ * answer is not whatever was built last. */
+ bool version_banner;
+ bool pane_buttons;
+ /* Marks of similar visual weight, so the even spacing between the buttons
+ * reads as even: a glyph that floats in a mostly-empty cell donates its own
+ * whitespace to the gap beside it and looks further away than its
+ * neighbours, which is what an en dash did here. */
+ /* 32 bytes because a mark is a grapheme cluster, not a character: a
+ family emoji is eighteen bytes on its own. Truncating one mid-
+ codepoint used to put invalid UTF-8 on the wire. */
+ char zoom_mark[32], zoom_on_mark[32], close_mark[32], min_mark[32];
+ /* The new-tab button at the end of the strip. Drawn with a space each side,
+ * so a one-character mark is still a three-cell target. */
+ char newtab_mark[32];
+ bool bell_indicator;
+ /* What to draw. A short string rather than a fixed glyph: the obvious
+ * choice is an emoji bell, and emoji are drawn two columns wide by many
+ * terminals while chrome here is booked as one — so the default is narrow
+ * and anyone who knows their terminal can say otherwise. */
+ char bell_mark[32];
+ /* Which dead panes stay, showing what they printed and offering to run
+ * again, and which just go.
+ *
+ * The line is whether the pane was given a *command*. A pane told to run
+ * `npm run dev` is part of the session's shape and its death is news: you
+ * want the error, and you want the button that runs it again. A pane that
+ * is a shell -- one you split off to do something in -- is finished when
+ * you type `exit`, and leaving its corpse on screen to be dismissed is
+ * exactly the fussiness a terminal should not have.
+ *
+ * Not *who made it*: a shell is a shell whether it came from a layout file
+ * or from C-a \. That also means a session restored from dump-layout
+ * behaves like the one it was dumped from, since the dump writes `command=`
+ * only for panes that had one. */
+ enum { KEEP_DEAD_NONE, KEEP_DEAD_COMMANDS, KEEP_DEAD_ALL } keep_dead;
+ uint16_t min_pane_cols, min_pane_rows;
+ /* The smallest pane a split is allowed to *produce*. min_pane is the point
+ * below which the layout gives up and collapses a pane; this is the point
+ * below which splitting stopped being worth offering, which is a larger
+ * number and a different question. Never used below min_pane: refusing to
+ * offer something the layout would collapse anyway is the floor. */
+ uint16_t min_split_cols, min_split_rows;
+ uint16_t scroll_lines; /* rows per wheel notch */
+ /* How much history a pane keeps. `scrollback` is the number people mean --
+ * lines -- and `scrollback_bytes` is the ceiling that keeps one very wide,
+ * heavily styled pane from spending the machine to honour it: lib-vt applies
+ * whichever limit is reached first, and both depend on things a line count
+ * cannot see. 0 lines is no scrollback; 0 bytes is no ceiling.
+ *
+ * Not a `uint16_t`, because 65,535 is a number somebody will want to exceed
+ * and a silently wrapped limit is worse than a refused one. */
+ size_t scrollback;
+ size_t scrollback_bytes;
+ uint16_t toast_ms; /* how long an announcement stays up */
+ uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */
+ uint16_t double_click_ms; /* how close two clicks must be to be a double */
+ /* What a double-click's word stops at, listed as *separators* rather than as
+ * word characters: anything unlisted belongs to a word, so text nobody
+ * enumerated -- CJK, accents, emoji -- needs no table. Whitespace always
+ * separates, listed or not. */
+ char word_separators[64];
+ /* How far everything behind a modal is pushed back, 0..255, 0 being off.
+ * A modal that floats over a screen as bright as it is reads as another
+ * pane rather than as something in front. */
+ uint8_t modal_scrim;
+ /* How far the panes you are *not* in are pushed back, 0..255 and 0 for not
+ * at all. A knob rather than only a `states { unfocused { } }` chain,
+ * because "dim the other panes" is a thing people want by name and should
+ * not have to know the shader vocabulary to ask for -- and because the
+ * argument against shipping it on was that turning it off should be
+ * obvious, which a single number is and a chain is not.
+ *
+ * Writing the state chain by hand still wins: the knob only fills it in
+ * when the config has not. */
+ uint8_t dim_unfocused;
+
+ /* Colour passes every pane gets, in the order they were written. Ordinary
+ * shaders rather than policy: the session has no opinion about these, you
+ * asked for them. */
+ shader_t shaders[SHADE_MAX];
+ size_t nshaders;
+
+ /* What a pane looks like when it is in a particular state. A pane is
+ * usually in several at once — unfocused *and* scrolled, suspended *and*
+ * something you could drop onto — so exactly one wins, the first that
+ * matches in the order below. That order is fixed rather than taken from
+ * the config, because it is a ranking by urgency and not a preference: a
+ * pane you are holding should not be recoloured by anything, and a mode the
+ * whole screen is in outranks an ambient hint about one pane.
+ *
+ * Stacking them instead would let two reasons to be grey compound into one
+ * muddy grey that reads as neither, which is the mistake the drag policy
+ * already had to avoid by hand. */
+ shader_t state_shaders[PSTATE_COUNT][SHADE_MAX];
+ size_t state_n[PSTATE_COUNT];
+
+ /* Whether any file in this load named `scrolled` (or `bell`) itself. The
+ * default wash over scrollback and the default bell flash are derived from
+ * the theme *after* each file's theme block -- scroll_bg and bell
+ * respectively -- so a theme moves them with their indicators. But a chain
+ * somebody wrote, in this file or in one it included, must stand. A local
+ * flag cannot say that across an include chain, so they live here and are
+ * cleared with the rest of the defaults. */
+ bool scrolled_declared;
+ bool bell_declared;
+
+ /* The same two things again, over a pane's *frame* rather than its contents:
+ * any entry in `shaders` or in a state's block carrying `where="chrome"`
+ * lands here instead. Separate chains rather than a flag on each shader,
+ * because a pass is per rect: the contents and the frame are two rects and
+ * therefore two passes, and a chain is exactly the list one pass runs. It
+ * also keeps the flag out of the plugin ABI, which has no business knowing
+ * where a session chose to run an effect. */
+ shader_t chrome_shaders[SHADE_MAX];
+ size_t nchrome_shaders;
+ shader_t chrome_state_shaders[PSTATE_COUNT][SHADE_MAX];
+ size_t chrome_state_n[PSTATE_COUNT];
+ /* How often to repaint while something on screen is animated -- which means
+ * a shader whose amount reads the clock. Nothing else needs a frame clock:
+ * every other repaint has an event behind it. 0 turns animation off, in the
+ * sense that an animated shader then only advances when something else
+ * causes a frame. */
+ uint16_t anim_ms;
+ bool status_bar; /* the strip along the top: tabs, prefix, pane count */
+ bool status_line; /* the line along the bottom: what you are looking at */
+ /* How far the strip and the line are held off the left and right edges.
+ * Separate from `gap`, which is the space around the *panes*: the two happen
+ * to match by default and there is no reason they must. */
+ uint16_t status_pad;
+ bool focus_follows_mouse;
+
+ /* Whether the program running in a pane may set that pane's shader chains
+ * over OSC 5577. Off, because a program restyling the session it happens to
+ * be running in is a hazard and not a feature (D13) -- and on for anyone
+ * prototyping a chain, because the alternative is edit, save, look, and the
+ * whole point of a colour pass is that you can see it.
+ *
+ * Scoped to the pane that asked: it cannot touch another pane, the chrome
+ * outside itself, or anything the config said about everybody else. */
+ bool in_band_shaders;
+
+ /* What a cell's "terminal default" colour resolves to when a shader has to
+ * compute on it. We cannot know the client's real default, and most text is
+ * default-coloured, so a shader would otherwise leave nearly everything
+ * alone. Only consulted while shading: an unshaded pane still defers to the
+ * terminal exactly as before. */
+ color_t default_fg, default_bg;
+
+ /* theme
+ *
+ * One name per decision. These began as six names doing thirty jobs, which
+ * meant the split guide could not be recoloured without also recolouring the
+ * focused frame, because they were the same entry. Several still share a
+ * default — that is a statement about what looks right together, not about
+ * them being the same thing. */
+
+ /* panes */
+ color_t frame_focus, frame_idle;
+ color_t title_focus, title_idle;
+
+ /* things you can press: the pane's own OSC 5577 buttons */
+ color_t button_fg, button_bg, button_bg_idle;
+
+ /* affordances that appear under the pointer */
+ color_t guide; /* the split guide: armed edge and dashed boundary */
+ color_t resize; /* the handle in the gap between two panes */
+ color_t drop_target; /* the frame of a pane a drag could land on */
+
+ /* the scrollback indicator in a pane's frame */
+ color_t scroll_fg, scroll_bg;
+
+ /* rows of a tab that has collapsed into a list */
+ color_t header, header_hover, header_hover_title;
+
+ /* the strip along the top */
+ color_t tab_active_fg, tab_active_bg;
+ /* The active tab keeps its fill while the pointer is on it -- it is still
+ * the tab you are in -- so its hover shows in the text instead. */
+ color_t tab_active_hover_fg;
+ color_t tab_idle, tab_hover;
+ color_t prefix_fg, prefix_bg; /* the "C-a" badge */
+ color_t tab_count; /* how many panes in the session */
+
+ /* the line along the bottom */
+ color_t status, status_state;
+
+ /* the pane finder */
+ color_t finder_fg, finder_bg, finder_sel_fg, finder_sel_bg;
+
+ /* the in-place editor a double-click opens on a pane's title */
+ color_t rename_fg, rename_bg;
+
+ /* the bell mark in a pane's titlebar */
+ color_t bell;
+
+ /* what a dead pane says: the line in its backlog, and the word in the
+ * status line and its own frame */
+ color_t dead;
+
+ /* the frame's own buttons */
+ color_t pane_button, pane_button_hover;
+
+ /* the row of minimised panes along the bottom */
+ color_t minbar, minbar_hover;
+
+ /* the hint in the middle of the status line */
+ color_t hint;
+
+ /* Modals: a surface that floats over the layout, with its own frame. Named
+ * separately from the pane colours it resembles because it is a different
+ * *surface* — it sits on top of everything, it is opaque, and it has to
+ * stay legible against a background that has been dimmed underneath it.
+ * Borrowing the pane button colour here is what made the close button
+ * invisible: dim on dim. */
+ color_t modal_fg, modal_bg;
+ color_t modal_border, modal_title;
+ color_t modal_button, modal_button_hover;
+
+ /* announcements */
+ color_t toast_fg, toast_bg;
+
+ /* keys */
+ int prefix_key;
+ uint16_t prefix_mods;
+ binding_t *binds;
+ size_t nbinds;
+
+ /* Every amount expression this config compiled. A shader_t is copied by
+ * value all over the place and several copies can point at one program, so
+ * ownership lives here — with the config that parsed them, freed when it is
+ * replaced. */
+ expr_prog_t **exprs;
+ size_t nexprs;
+
+ /* what a new pane runs; NULL means $SHELL */
+ char *shell;
+ /* What `edit-config` opens the file with; NULL means $EDITOR, then vi. */
+ char *editor;
+
+ /* Where to look for shader plugins (`*.so`). NULL means the default: a
+ * `shaders` directory beside the config file. Read before the `shaders` and
+ * `states` blocks are parsed, because what they are allowed to name depends
+ * on what has been loaded. */
+ char *shader_dir;
+
+ /* Where projects live: `project_roots "~/dev" "~/work" depth=2`. A project is
+ * a subdirectory of one of these with a `slosh.layout.kdl` in it -- or a
+ * `.git`, which gets `project_layout` instead. Empty means the feature is
+ * dormant and costs nothing.
+ *
+ * `depth` is levels below each root, and a directory that *is* a project is
+ * never descended into, so 2 covers `~/dev/work/api` without walking any
+ * checkout's insides. */
+ char *project_roots[PROJECT_ROOTS_MAX];
+ size_t nproject_roots;
+ int project_depth;
+
+ /* The layout a project with no file of its own opens as. Yours rather than a
+ * guess about your stack: relative paths in it bind to whichever project is
+ * being opened, so one file is the shape you start every project in. NULL is
+ * one pane running your shell. */
+ char *project_layout;
+
+ /* Every file this config was built from, in the order they were read: the one
+ * that was loaded and everything it included, whether or not each existed.
+ * Kept because the watcher needs it — a theme you can include is a theme you
+ * expect to reload when you save it — and recorded even for a file that is
+ * not there, so a session started before you wrote one still notices it
+ * appearing. */
+ char *files[CONFIG_FILES_MAX];
+ size_t nfiles;
+
+ /* Everything this load had to complain about, in the order it found them,
+ * each already carrying the file and line it happened at. A session shows the
+ * first (there is one status line); `--check` shows them all, which is the
+ * whole difference between a warning and a linter. */
+ char msgs[CONFIG_MSGS_MAX][192];
+ size_t nmsgs;
+
+ /* Loader scratch: the file being read right now, so a complaint can say which
+ * one it came from without every call site being handed the path. Meaningless
+ * once loading has finished. */
+ const char *loading;
+} config_t;
+
+/* Defaults, then <config dir>/config.kdl on top. Never fails: on a bad file
+ * it keeps the defaults and writes the reason to `err`. */
+void config_defaults(config_t *c);
+bool config_load(config_t *c, const char *path, char *err, size_t errcap);
+/* $SLOSH_CONFIG, else $XDG_CONFIG_HOME/slosh/config.kdl, else ~/.config/… */
+const char *config_default_path(void);
+/* Every complaint from the last load, oldest first: "config.kdl:12: ...".
+ * Returns how many were written. */
+size_t config_messages(const config_t *c, const char **out, size_t max);
+void config_free(config_t *c);
+/* The files this config was read from, oldest first: `files[0]` is the one that
+ * was loaded, the rest are what it included. Returns how many were written. */
+size_t config_files(const config_t *c, const char **out, size_t max);
+
+/* Shader chains from text rather than from a file: one entry, several separated by
+ * `;`, or a whole `shaders { ... }` block. Every shape a config file has, because
+ * the point of using the config's syntax at a prompt is that the two directions
+ * match -- what you prototype is what you paste, and what you paste is something
+ * you can type back.
+ *
+ * Each pass goes where its own `where=` says; `default_chrome` is only what a pass
+ * that keeps quiet means. Here because the parser belongs with the config: a chain
+ * typed at a running pane and a chain in a config file have to mean the same
+ * thing, or prototyping teaches you something that then does not work.
+ *
+ * Both arrays hold SHADE_MAX and `exprs` twice that; the compiled programs belong
+ * to the caller, since a pane's chain outlives no load in particular. Returns how
+ * many entries were understood, writing the first refusal to `err`. */
+size_t config_parse_chain_doc(const char *text, color_t default_color,
+ bool default_chrome, shader_t *content,
+ size_t *ncontent, shader_t *chrome,
+ size_t *nchrome, expr_prog_t **exprs,
+ size_t *nexprs, char *err, size_t errcap);
+
+/* The same, from a file: a `shaders { }` block as `contrib/chrome` and
+ * `contrib/shaders` ship them, read with the parser that already knows this
+ * format. Entries are routed to the two chains by their own `where=`, defaulting
+ * to content as a config file does, so a preset lands where its author said.
+ * Anything else at the top level is ignored -- hand it a whole config.kdl and
+ * only the shaders apply. Both arrays hold SHADE_MAX; `exprs` holds twice that.
+ * Returns how many entries were understood. */
+size_t config_parse_chain_file(const char *path, color_t default_color,
+ shader_t *content, size_t *ncontent,
+ shader_t *chrome, size_t *nchrome,
+ expr_prog_t **exprs, size_t *nexprs, char *err,
+ size_t errcap);
+
+/* Whether a name is one of the config's own settings. For the layout loader, which
+ * shares this syntax and needs to tell a config apart from a session file -- one
+ * list, so the two answers cannot disagree. */
+bool config_is_setting(const char *name);
+
+/* What a chord does after the leader. Direct bindings answer here too, so
+ * pressing the leader first never makes a binding stop working. */
+action_t config_lookup(const config_t *c, int key, uint16_t mods);
+/* What a chord does on its own, with no leader pressed. ACT_NONE for the
+ * overwhelming majority of keys, which is what lets them reach the pane. */
+action_t config_lookup_direct(const config_t *c, int key, uint16_t mods);
+/* Whether any binding fires without the leader, so the cheatsheet knows
+ * whether it has a section to draw. */
+bool config_has_direct(const config_t *c);
+/* The reverse of config_parse_chord: what to call a binding on screen. A
+ * shifted letter is written the way you would type it ("H"), because "S-h" is
+ * a description of a keystroke rather than a keystroke. */
+void config_chord_name(int key, uint16_t mods, char *out, size_t cap);
+/* A phrase for the cheatsheet, and which group it belongs under. `group` is
+ * one of a fixed set, in the order they should be shown. */
+const char *config_action_label(action_t a);
+/* The name this action is written as in a config file. */
+const char *config_action_name(action_t a);
+/* The whole config as a file you could have written, with the values it
+ * currently holds. Generated rather than kept on disk, because a checked-in
+ * copy of the defaults is a second source of truth and drifts. Caller frees. */
+char *config_render(const config_t *c);
+/* The same, for a config nobody has edited: what a fresh install would do.
+ * Caller frees. */
+char *config_dump_defaults(void);
+const char *config_action_group(action_t a);
+/* "ctrl+a", "alt+left", "\\", "f" -> key + mods. False if unparseable. */
+bool config_parse_chord(const char *text, int *out_key, uint16_t *out_mods);
+
+#endif /* SLOSH_CONFIG_H */
diff --git a/include/expr.h b/include/expr.h
new file mode 100644
index 0000000..d593319
--- /dev/null
+++ b/include/expr.h
@@ -0,0 +1,102 @@
+/* Shader expressions: a tiny integer language for the one number a shader
+ * takes, compiled to bytecode at config load.
+ *
+ * The idea is smaller than "shaders in a language". Every built-in shader here
+ * is already `colour_op(amount)` where the amount is a pure function of where
+ * the cell is:
+ *
+ * vignette amount = f(x, y) ruler amount = (x == col) * amount
+ * zebra amount = f(y) margin amount = (x >= col) * amount
+ *
+ * So the part worth handing to a config is the *amount*, not the colour maths.
+ * That keeps the mixing in C where it is fast and correct, gives an expression
+ * no way to produce an invalid cell (it produces one byte), and \u2014 because the
+ * result is a pure function of position \u2014 lets the whole program be evaluated
+ * once into a per-cell map and then reused, which is what makes an interpreter
+ * affordable at all. Measured: 56ns/cell interpreted, 2ns/cell from the map.
+ *
+ * The language is deliberately total. No loops, no recursion, no jumps: even
+ * `?:` compiles to evaluating both sides and selecting, which is safe because
+ * nothing in here has an effect. Evaluation is therefore O(program length),
+ * a bad expression cannot hang a session, and there is nothing to sandbox.
+ *
+ * variables x y cols rows curx cury cursor focused t since above below
+ * operators + - * / % < > <= >= == != && || ! ?:
+ * functions min max abs clamp dist
+ *
+ * `x`/`y` are cell positions inside the rect the pass runs over (a pane's
+ * contents, or its frame for a chrome pass), `t` is a millisecond clock for
+ * animation, `since` is how many milliseconds the pane has been in the state
+ * it is in — which is what a one-shot effect needs, because `t` says what time
+ * it is and not how long ago something happened. `above`/`below` are the lines
+ * of scrollback hidden past the viewport's top and bottom edges, both 0 at
+ * the present — which is what lets an effect say "there is more this way".
+ * `dist` corrects for a cell being about twice as tall as it is wide. Division and modulo by zero are 0
+ * rather than a trap: an expression that is wrong should look wrong, not take
+ * the session with it.
+ */
+#ifndef SLOSH_EXPR_H
+#define SLOSH_EXPR_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+typedef struct expr_prog expr_prog_t;
+
+/* What a program reads, worked out by the compiler rather than declared. This
+ * is what decides whether its output can be cached and against what, so it is
+ * derived from the source and can never disagree with it. */
+enum {
+ EXPR_DEP_POS = 1 << 0, /* x, y */
+ EXPR_DEP_CURSOR = 1 << 1, /* curx, cury, cursor */
+ EXPR_DEP_TIME = 1 << 2, /* t: recomputed every frame, never cached */
+ EXPR_DEP_FOCUS = 1 << 3, /* focused */
+ /* cols, rows. Not part of the cache key -- the key always carries the rect,
+ * because a map is per rect by construction -- but it still has to be a
+ * dependency, or a program reading only the size would look constant and be
+ * folded at compile time, where the size is not known yet and is zero. */
+ EXPR_DEP_SIZE = 1 << 4,
+ EXPR_DEP_SCROLL = 1 << 5, /* above, below */
+};
+
+/* Compile, or NULL with a reason in `err`. A program that reads nothing is
+ * folded to its value at compile time and still returned, so callers have one
+ * shape to handle. */
+expr_prog_t *expr_compile(const char *src, char *err, size_t errcap);
+void expr_free(expr_prog_t *p);
+
+/* What it reads (EXPR_DEP_*). 0 means the result is a constant. */
+unsigned expr_deps(const expr_prog_t *p);
+/* The value of a program that reads nothing; undefined otherwise. */
+int expr_constant(const expr_prog_t *p);
+
+/* Everything a program can see. Filled by the shader pass. */
+typedef struct {
+ int x, y, cols, rows;
+ int curx, cury, cursor;
+ int focused;
+ int64_t t;
+ /* Milliseconds since the pane entered its current state, clamped at 0. */
+ int64_t since;
+ /* Lines of scrollback hidden above and below the viewport, both 0 at the
+ * present (and for a pass over anything that is not a pane's contents). */
+ int above, below;
+} expr_env_t;
+
+/* Evaluate for one cell. Clamped to 0..255 by the caller, not here: the
+ * language has no opinion about what its number is for. */
+int expr_eval(const expr_prog_t *p, const expr_env_t *env);
+
+/* The text this program was compiled from, owned by the program. For the
+ * config dump, which has to write back the line somebody wrote rather than
+ * pretend an expression was a number. */
+const char *expr_source(const expr_prog_t *p);
+
+/* Evaluate for every cell of a cols x rows rect into `out` (cols*rows bytes,
+ * clamped to 0..255), reusing the last result when nothing it reads has
+ * changed. Returns false when the program reads the clock, which is the one
+ * case there is nothing to reuse. */
+bool expr_amount_map(expr_prog_t *p, const expr_env_t *env, uint8_t **out);
+
+#endif /* SLOSH_EXPR_H */
diff --git a/include/graphics.h b/include/graphics.h
new file mode 100644
index 0000000..b3cd6ec
--- /dev/null
+++ b/include/graphics.h
@@ -0,0 +1,88 @@
+/* Kitty graphics passthrough.
+ *
+ * libghostty-vt parses the protocol and tracks every image and placement, so
+ * a pane's images exist as data; what a multiplexer has to add is *re-emitting*
+ * them to the client's terminal at the right place, with ids that cannot
+ * collide between panes.
+ *
+ * This is why pi has to fall back to sixel under tmux and zellij: they drop
+ * the images entirely. Here the native path works.
+ */
+#ifndef SLOSH_GRAPHICS_H
+#define SLOSH_GRAPHICS_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+typedef struct graphics graphics_t;
+
+/* One image, as this session knows it. */
+typedef struct {
+ uint32_t pane; /* which pane transmitted it */
+ uint32_t src_id; /* the id the program chose, unique only within its pane */
+ uint32_t out_id; /* the id we give the client, unique across the session */
+ uint64_t gen; /* image generation, so a changed image is re-sent */
+ bool sent;
+} gfx_image_t;
+
+/* One placement on screen this frame. */
+typedef struct {
+ uint32_t out_id;
+ uint32_t place_id;
+ uint16_t col, row; /* screen cells */
+ uint16_t cols, rows;
+ /* Where the image starts *inside* its first cell, in pixels. This is what
+ * makes motion smooth instead of snapping a cell at a time, and dropping it
+ * is invisible in a still picture and obvious the moment anything moves. */
+ uint32_t x_off, y_off;
+ /* Cells to scale into, or 0 to draw at natural size. Only sent on when the
+ * program asked for it. */
+ uint16_t scale_cols, scale_rows;
+ uint32_t sx, sy, sw, sh; /* source rectangle, in image pixels */
+ bool live;
+} gfx_place_t;
+
+graphics_t *gfx_new(void);
+void gfx_free(graphics_t *g);
+
+/* Start a frame: everything placed last frame is marked stale. */
+void gfx_begin(graphics_t *g);
+/* Everything gfx_place() needs: where it goes, what it crops to, and the
+ * image itself for the first time it is seen.
+ *
+ * A struct rather than the twenty positional arguments this used to be. Half
+ * of them are uint32_t pixel quantities, so a transposed pair compiles
+ * perfectly and shows up as a picture in slightly the wrong place — which is
+ * exactly the bug class this file is for. */
+typedef struct {
+ uint32_t pane; /* which pane it belongs to */
+ uint32_t src_id; /* the id the program chose */
+ uint64_t gen; /* image generation, so a changed image is re-sent */
+ uint32_t place_id;
+ uint16_t col, row; /* screen cells */
+ uint16_t cols, rows; /* cells covered */
+ uint16_t scale_cols, scale_rows; /* cells to scale into, 0 for natural */
+ uint32_t x_off, y_off; /* offset within the first cell, in pixels */
+ uint32_t sx, sy, sw, sh; /* source rectangle, in image pixels */
+ uint32_t px_w, px_h; /* the image's own size */
+ int format, compression;
+ const uint8_t *data; /* NULL once the client has it */
+ size_t data_len;
+} gfx_req_t;
+
+/* Record a visible placement, transmitting the image if the client has not
+ * seen it yet. `data` may be NULL when the image is already transmitted. */
+void gfx_place(graphics_t *g, const gfx_req_t *req);
+/* End a frame: emit transmits, placements and deletions. Caller frees. */
+char *gfx_flush(graphics_t *g, size_t *out_len);
+
+/* Forget what the client has seen (it is a different client now). */
+void gfx_reset(graphics_t *g);
+/* Drop everything belonging to a pane that is gone. */
+void gfx_forget_pane(graphics_t *g, uint32_t pane);
+
+size_t gfx_placements(const graphics_t *g, const gfx_place_t **out);
+size_t gfx_images(const graphics_t *g, const gfx_image_t **out);
+
+#endif /* SLOSH_GRAPHICS_H */
diff --git a/include/input.h b/include/input.h
new file mode 100644
index 0000000..adda27a
--- /dev/null
+++ b/include/input.h
@@ -0,0 +1,94 @@
+/* Input decoding: the bytes the *outer* terminal sends us -> semantic events.
+ *
+ * libghostty-vt encodes events outward to a pane, but nothing decodes inward,
+ * so this half is ours (DESIGN.md). Decoding to a semantic event and
+ * re-encoding per pane against that pane's own modes is what makes kitty
+ * keyboard passthrough correct instead of approximate.
+ *
+ * The event carries GhosttyKey / GHOSTTY_MODS_* values as plain integers so
+ * this header stays free of libghostty-vt.
+ */
+#ifndef SLOSH_INPUT_H
+#define SLOSH_INPUT_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+typedef enum {
+ EV_NONE = 0,
+ EV_KEY,
+ EV_MOUSE,
+ EV_PASTE,
+ EV_FOCUS,
+} ev_kind_t;
+
+/* mirrors GhosttyKeyAction */
+enum { KEY_RELEASE = 0, KEY_PRESS = 1, KEY_REPEAT = 2 };
+/* mirrors GhosttyMouseAction */
+enum { MOUSE_PRESS = 0, MOUSE_RELEASE = 1, MOUSE_MOTION = 2 };
+/* mirrors GhosttyMouseButton */
+enum {
+ MBTN_UNKNOWN = 0,
+ MBTN_LEFT = 1,
+ MBTN_RIGHT = 2,
+ MBTN_MIDDLE = 3,
+ MBTN_FOUR = 4,
+ MBTN_FIVE = 5,
+ MBTN_SIX = 6,
+ MBTN_SEVEN = 7,
+};
+/* mirrors GHOSTTY_MODS_* */
+enum {
+ MOD_SHIFT = 1 << 0,
+ MOD_CTRL = 1 << 1,
+ MOD_ALT = 1 << 2,
+ MOD_SUPER = 1 << 3,
+ MOD_CAPS = 1 << 4,
+ MOD_NUM = 1 << 5,
+};
+
+typedef struct {
+ ev_kind_t kind;
+
+ /* EV_KEY */
+ int key; /* GhosttyKey */
+ uint16_t mods; /* MOD_* */
+ uint8_t action; /* KEY_* */
+ char text[16]; /* UTF-8 the key produced, if any */
+ uint8_t text_len;
+ uint32_t unshifted; /* unshifted codepoint, 0 if unknown */
+
+ /* EV_MOUSE */
+ uint8_t button; /* MBTN_* */
+ uint8_t maction; /* MOUSE_* */
+ uint16_t mx, my; /* 0-indexed cell coordinates */
+
+ /* EV_PASTE */
+ const char *paste;
+ size_t paste_len;
+
+ /* EV_FOCUS */
+ bool focused;
+} input_event_t;
+
+typedef struct input_parser input_parser_t;
+typedef void (*input_cb_t)(const input_event_t *ev, void *ud);
+
+input_parser_t *input_new(void);
+void input_free(input_parser_t *p);
+
+/* Feed bytes; complete events are dispatched to cb. Partial sequences are
+ * retained for the next call, so split reads are safe. */
+void input_feed(input_parser_t *p, const uint8_t *data, size_t len,
+ input_cb_t cb, void *ud);
+
+/* True when a bare ESC (or an unterminated sequence) is buffered. Call
+ * input_timeout() after ~50ms of quiet to resolve it as the Escape key. */
+bool input_pending(const input_parser_t *p);
+void input_timeout(input_parser_t *p, input_cb_t cb, void *ud);
+
+/* Debug/test rendering of an event, e.g. "key ARROW_UP mods=CTRL press". */
+void input_event_describe(const input_event_t *ev, char *buf, size_t cap);
+
+#endif /* SLOSH_INPUT_H */
diff --git a/include/json.h b/include/json.h
new file mode 100644
index 0000000..f9442ad
--- /dev/null
+++ b/include/json.h
@@ -0,0 +1,28 @@
+/* A minimal JSON writer. No parser: we only ever emit. */
+#ifndef SLOSH_JSON_H
+#define SLOSH_JSON_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+typedef struct {
+ char *buf;
+ size_t len, cap;
+ bool need_comma;
+} json_t;
+
+void json_init(json_t *j);
+void json_free(json_t *j);
+
+void json_obj_open(json_t *j, const char *key); /* key may be NULL in arrays */
+void json_obj_close(json_t *j);
+void json_arr_open(json_t *j, const char *key);
+void json_arr_close(json_t *j);
+
+void json_str(json_t *j, const char *key, const char *val, size_t len);
+void json_int(json_t *j, const char *key, long long val);
+void json_bool(json_t *j, const char *key, bool val);
+void json_null(json_t *j, const char *key);
+
+#endif /* SLOSH_JSON_H */
diff --git a/include/jsonval.h b/include/jsonval.h
new file mode 100644
index 0000000..1eedb4c
--- /dev/null
+++ b/include/jsonval.h
@@ -0,0 +1,45 @@
+/* A small JSON reader, for the control API (D3). We already emit JSON; this
+ * is the other half. Deliberately minimal: no streaming, no comments, no
+ * duplicate-key policy beyond "last wins". */
+#ifndef SLOSH_JSONVAL_H
+#define SLOSH_JSONVAL_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+typedef enum {
+ JV_NULL,
+ JV_BOOL,
+ JV_NUM,
+ JV_STR,
+ JV_ARR,
+ JV_OBJ,
+} jv_kind_t;
+
+typedef struct jv jv_t;
+
+struct jv {
+ jv_kind_t kind;
+ bool b;
+ double num;
+ char *str; /* JV_STR: NUL-terminated, UTF-8 */
+ size_t len; /* JV_STR bytes; JV_ARR/JV_OBJ element count */
+ jv_t **kids; /* JV_ARR/JV_OBJ values */
+ char **keys; /* JV_OBJ keys */
+};
+
+/* NULL on malformed input. */
+jv_t *jv_parse(const char *text);
+void jv_free(jv_t *v);
+
+const jv_t *jv_get(const jv_t *obj, const char *key);
+const char *jv_str(const jv_t *v, const char *fallback);
+long jv_int(const jv_t *v, long fallback);
+bool jv_bool(const jv_t *v, bool fallback);
+
+/* Convenience: obj.key as a string/int/bool, with a fallback. */
+const char *jv_gets(const jv_t *obj, const char *key, const char *fallback);
+long jv_geti(const jv_t *obj, const char *key, long fallback);
+bool jv_getb(const jv_t *obj, const char *key, bool fallback);
+
+#endif /* SLOSH_JSONVAL_H */
diff --git a/include/kdl.h b/include/kdl.h
new file mode 100644
index 0000000..9787fd9
--- /dev/null
+++ b/include/kdl.h
@@ -0,0 +1,50 @@
+/* A hand-rolled KDL subset (D2).
+ *
+ * Enough of https://kdl.dev for configuration and layouts, and no more:
+ *
+ * name arg1 "arg two" key=value key2=3 {
+ * child "x"; child "y" // single-line children need ; terminators
+ * }
+ * // line comments, and block comments
+ *
+ * Values are kept as strings and converted on read, which keeps the parser
+ * small and puts type decisions where the meaning is.
+ */
+#ifndef SLOSH_KDL_H
+#define SLOSH_KDL_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+typedef struct {
+ char *key;
+ char *val;
+} kdl_prop_t;
+
+typedef struct kdl_node {
+ char *name;
+ char **args;
+ size_t nargs;
+ kdl_prop_t *props;
+ size_t nprops;
+ struct kdl_node **kids;
+ size_t nkids;
+ int line;
+} kdl_node_t;
+
+/* Parses a document into a synthetic root node whose children are the
+ * top-level nodes. Returns NULL and fills err (if given) on a syntax error. */
+kdl_node_t *kdl_parse(const char *text, char *err, size_t errcap);
+kdl_node_t *kdl_parse_file(const char *path, char *err, size_t errcap);
+void kdl_free(kdl_node_t *n);
+
+const kdl_node_t *kdl_child(const kdl_node_t *n, const char *name);
+const char *kdl_arg(const kdl_node_t *n, size_t i, const char *fallback);
+long kdl_arg_int(const kdl_node_t *n, size_t i, long fallback);
+bool kdl_arg_bool(const kdl_node_t *n, size_t i, bool fallback);
+const char *kdl_prop(const kdl_node_t *n, const char *key,
+ const char *fallback);
+long kdl_prop_int(const kdl_node_t *n, const char *key, long fallback);
+bool kdl_prop_bool(const kdl_node_t *n, const char *key, bool fallback);
+
+#endif /* SLOSH_KDL_H */
diff --git a/include/osc5577.h b/include/osc5577.h
new file mode 100644
index 0000000..e6033f1
--- /dev/null
+++ b/include/osc5577.h
@@ -0,0 +1,56 @@
+/* OSC 5577: the pane status bar and action buttons.
+ *
+ * Byte-compatible with the sl0ppi fork (D1), so the pi extensions written
+ * against zellij work here unmodified:
+ *
+ * printf '\033]5577;1;status;building 3/7\033\\'
+ * printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\'
+ * printf '\033]5577;1;clear\033\\'
+ *
+ * and a click comes back on the pane's stdin as
+ *
+ * \033]5577;1;click;approve\033\\
+ *
+ * A reply is never a request. Everything the session sends back to a program
+ * ends its verb in `-reply` (`hello-reply`, `shader-reply`), and no request verb
+ * may, because a pane that echoes what it is sent -- `cat`, a shell with echo
+ * on, a REPL waiting for a line -- would otherwise be answered into a loop. The
+ * fork's `click` predates the rule and is safe for the weaker reason that
+ * nothing answers a click.
+ *
+ * libghostty-vt's UNKNOWN_SEQUENCE effect reports APC only, not unknown OSC,
+ * so we scan the pty stream ourselves. The bytes still go to lib-vt, which
+ * discards an OSC it does not know — nothing is drawn, and we need no
+ * buffering that could stall a pane's output.
+ */
+#ifndef SLOSH_OSC5577_H
+#define SLOSH_OSC5577_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#define OSC5577_MAX 4096
+
+typedef struct {
+ enum { OS_GROUND, OS_ESC, OS_BODY, OS_BODY_ESC } state;
+ char buf[OSC5577_MAX];
+ size_t len;
+ bool overflow;
+} osc_scan_t;
+
+/* verb and payload are NUL-terminated and valid for the callback only. */
+typedef void (*osc5577_fn)(const char *verb, const char *payload, void *ud);
+
+void osc_scan_reset(osc_scan_t *s);
+void osc_scan_feed(osc_scan_t *s, const uint8_t *data, size_t len,
+ osc5577_fn cb, void *ud);
+
+/* %3B %3A %25 and friends. Writes at most cap-1 bytes plus a NUL. */
+size_t osc5577_unescape(const char *in, size_t in_len, char *out, size_t cap);
+/* A button id is [A-Za-z0-9_-]{1,32}. Deliberately narrow: ids are echoed
+ * back to the program, and a program that trusts its own ids should not have
+ * to defend against what a hostile label could smuggle through. */
+bool osc5577_valid_id(const char *id);
+
+#endif /* SLOSH_OSC5577_H */
diff --git a/include/png.h b/include/png.h
new file mode 100644
index 0000000..ddd1b72
--- /dev/null
+++ b/include/png.h
@@ -0,0 +1,22 @@
+/* PNG decoding for the kitty graphics protocol.
+ *
+ * libghostty-vt ships no image codec: built as a library it sets its
+ * `decode_png` hook to null, and a null hook means every `f=100` transmission
+ * is rejected outright — no image stored, no placement, nothing for the
+ * graphics layer to re-emit. A program that uploads a PNG (which is what a
+ * program with a picture on disk naturally does) therefore drew nothing at
+ * all, silently, while raw RGB worked. Installing this hook is the whole fix.
+ *
+ * PNG is the only format this can be about: the protocol has exactly three
+ * (`f=24` RGB, `f=32` RGBA, `f=100` PNG) and the first two are raw pixels the
+ * library already handles. Decoding only, never encoding — we hand the client
+ * RGBA.
+ */
+#ifndef SLOSH_PNG_H
+#define SLOSH_PNG_H
+
+/* Install the process-global PNG decoder. Must be called before any terminal
+ * exists, and is idempotent. */
+void png_init(void);
+
+#endif /* SLOSH_PNG_H */
diff --git a/include/project.h b/include/project.h
new file mode 100644
index 0000000..6bf3021
--- /dev/null
+++ b/include/project.h
@@ -0,0 +1,64 @@
+/* Projects on disk, and the workspaces they open as.
+ *
+ * A *project* is a directory somebody works in. A *workspace* is the tab it
+ * currently occupies in a session. This file is about the first: finding them,
+ * naming them, and saying where each one's layout lives. The second lives in
+ * app.c, because a workspace is tabs.
+ *
+ * The list is derived when it is asked for rather than watched. One readdir per
+ * root plus two faccessat per entry is forty checkouts for eighty-one syscalls,
+ * which is nothing next to the keystroke that asked -- and an answer nobody
+ * remembered cannot be stale, which no watcher can promise on a bind mount.
+ */
+#ifndef SLOSH_PROJECT_H
+#define SLOSH_PROJECT_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+/* The file a project uses to say what it needs. One name, so there is no
+ * precedence question: a layout file by D2's rule, checked by `--check`, and
+ * highlighted by anything that knows KDL. */
+#define PROJECT_LAYOUT_FILE "slosh.layout.kdl"
+
+/* How many projects one session will look at. A picker is not a filesystem
+ * browser: past this many the answer to "which project" is a search, not a
+ * list, and the list is what this is for. */
+#define PROJECTS_MAX 128
+
+typedef struct {
+ char name[64]; /* the directory's own name, which is what people call it */
+ char path[512]; /* absolute, resolved */
+ char slug[64]; /* `project:name.hash8` -- the purpose a workspace carries */
+ /* The project's own layout file, or "" for one inferred from a `.git`. The
+ * distinction is the whole of what "declared" means here, and the picker
+ * shows it: a project with no layout is an invitation to save one. */
+ char layout[512];
+ int64_t mtime; /* of `layout`, 0 when there is none. Drift is derivable from
+ * this and a pane's `since` without anything storing it. */
+} project_t;
+
+/* Every project under `roots`, sorted by name. `roots` is a NUL-terminated
+ * array of directories; `depth` is how many levels below each to look, and a
+ * directory that *is* a project is never descended into -- which is what keeps
+ * this off node_modules without a rule about node_modules.
+ *
+ * A root that does not exist is not an error. Neither is one with nothing in
+ * it: the setting is a statement of where you keep things, not a promise that
+ * you have any. */
+size_t project_scan(const char *const *roots, int depth, project_t *out,
+ size_t max);
+
+/* One project by name, or by path when `name` looks like one (it has a `/`).
+ * False when no root holds it -- which is also the containment check, since a
+ * path that no root holds is a path this session will not write to. */
+bool project_find(const char *const *roots, int depth, const char *name,
+ project_t *out);
+
+/* The purpose a workspace for this directory carries: `project:<name>.<hash>`
+ * of the resolved path. Hashed on the path rather than the name so that two
+ * worktrees of one repo are two workspaces, which is what they are. */
+void project_slug(const char *path, const char *name, char *out, size_t cap);
+
+#endif /* SLOSH_PROJECT_H */
diff --git a/include/proto.h b/include/proto.h
new file mode 100644
index 0000000..e6cd42c
--- /dev/null
+++ b/include/proto.h
@@ -0,0 +1,68 @@
+/* The client/server wire, and where sessions live.
+ *
+ * Frames are `u8 type, u32 length (big endian), payload`. Deliberately dull:
+ * the interesting protocol is the JSON control channel (M3) that rides on
+ * MSG_CMD, and it speaks the same vocabulary as the headless driver.
+ */
+#ifndef SLOSH_PROTO_H
+#define SLOSH_PROTO_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+/* MSG_HELLO and MSG_RESIZE carry u16 cols, u16 rows, and since the graphics
+ * fix two more: u16 cell_w, u16 cell_h, the client's cell size in pixels.
+ * A four-byte payload is still accepted and means "I did not say", which is
+ * what an older client sends and what a terminal that reports no pixel size
+ * leads to. */
+enum {
+ MSG_HELLO = 1, /* client -> server: u16 cols, rows[, cell_w, cell_h] */
+ MSG_INPUT = 2, /* client -> server: raw bytes from the terminal */
+ MSG_RESIZE = 3, /* client -> server: u16 cols, rows[, cell_w, cell_h] */
+ MSG_DETACH = 4, /* client -> server: leave, but keep running */
+ MSG_OUTPUT = 5, /* server -> client: bytes for the terminal */
+ MSG_EXIT = 6, /* server -> client: u8 reason */
+ MSG_CMD = 7, /* client -> server: one JSON line */
+ MSG_REPLY = 8, /* server -> client: one JSON line */
+};
+
+enum {
+ EXIT_SESSION_ENDED = 0, /* the last pane closed */
+ EXIT_DETACHED = 1, /* the user detached */
+ EXIT_REPLACED = 2, /* another client attached */
+};
+
+typedef struct {
+ uint8_t type;
+ uint8_t *data;
+ uint32_t len;
+} msg_t;
+
+/* Incremental framing: feed bytes, take whole messages out. */
+typedef struct {
+ uint8_t *buf;
+ size_t len, cap;
+ uint8_t *msg; /* the payload handed to the caller, lifted out of buf */
+ size_t msg_cap;
+} msg_reader_t;
+
+void msg_reader_init(msg_reader_t *r);
+void msg_reader_free(msg_reader_t *r);
+void msg_reader_feed(msg_reader_t *r, const uint8_t *data, size_t len);
+/* Returns true and fills `out` (data points into the reader's buffer, valid
+ * until the next call) when a whole message is available. */
+bool msg_reader_next(msg_reader_t *r, msg_t *out);
+
+/* Blocking send of one frame. Returns 0 on success, -1 if the peer is gone. */
+int msg_send(int fd, uint8_t type, const void *data, size_t len);
+
+/* $XDG_RUNTIME_DIR/slosh/<name>.sock, or /tmp/slosh-<uid>/<name>.sock.
+ * Creates the directory (0700). Returns 0 on success. */
+int session_socket_path(const char *name, char *out, size_t cap);
+int session_log_path(const char *name, char *out, size_t cap);
+/* Names of live sessions (sockets that answer). Caller frees each and the
+ * array. Returns the count. */
+size_t session_list(char ***out_names);
+
+#endif /* SLOSH_PROTO_H */
diff --git a/include/server.h b/include/server.h
new file mode 100644
index 0000000..b5d114a
--- /dev/null
+++ b/include/server.h
@@ -0,0 +1,28 @@
+/* Server and client entry points. See proto.h for the wire. */
+#ifndef SLOSH_SERVER_H
+#define SLOSH_SERVER_H
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#include "slosh.h"
+
+/* Run the session in this process (the daemonised half). `watch` re-reads the
+ * config when the file changes; see server.c for why that is a flag and not a
+ * config setting. */
+/* What panes are told about the session around them: its name, or NULL for a
+ * mode that has no socket, which must clear it rather than inherit a lie. */
+void session_env(const char *name);
+
+int server_run(const char *name, const char *const argv[], uint16_t cols,
+ uint16_t rows, const char *layout, bool watch);
+/* Fork a server, then wait for its socket to answer. Returns a connected fd. */
+int server_spawn(const char *name, const char *const argv[], uint16_t cols,
+ uint16_t rows, const char *layout, bool watch);
+/* Connect to an existing session, or -1. */
+int server_connect(const char *name);
+
+int client_run(int fd);
+int client_control(int fd, const char *line);
+
+#endif /* SLOSH_SERVER_H */
diff --git a/include/shader.h b/include/shader.h
new file mode 100644
index 0000000..a6d9f65
--- /dev/null
+++ b/include/shader.h
@@ -0,0 +1,83 @@
+/* Terminal shaders: per-cell colour computation over a pane's CONTENTS.
+ *
+ * A shader is a pure function from (cell, position, parameters) to that cell's
+ * colours. It runs after pane_compose() has written the pane's viewport into
+ * the screen and before the split guide is drawn over it, which is what makes
+ * "contents, not chrome" fall out of the existing paint order rather than
+ * needing a rule: the frame is painted before and lives outside the content
+ * rect, and an affordance drawn after stays legible on top of a dimmed pane.
+ *
+ * Cells only. A shader may write fg, bg and attrs; it may not touch text, len
+ * or width. Rewriting text would desync selection and copy (which read the
+ * terminal, not the screen) and could break the wide-cell invariant.
+ *
+ * Each cell is shaded independently — no neighbours — so effects are limited
+ * to what position and colour can express. That is most of them: tint, dim,
+ * grayscale, gradients, vignettes, anything positional. Convolutions (blur,
+ * bloom) would need a second buffer and are deliberately not possible here.
+ *
+ * Shaders chain: each is a complete pass, so `grayscale` then `tint` differs
+ * from `tint` then `grayscale`, and both are expressible.
+ */
+#ifndef SLOSH_SHADER_H
+#define SLOSH_SHADER_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include "shader_abi.h"
+#include "slosh.h"
+
+/* Per pane, and separately per config. Small and fixed: a stack this deep is
+ * already a lot of passes over the same cells. */
+#define SHADE_MAX 4
+/* What one pane can end up running: the ones every pane gets from the config,
+ * the ones attached to this pane, and the one or two the session derived for
+ * this frame. */
+#define SHADE_CHAIN_MAX (SHADE_MAX * 2 + 2)
+
+/* shade_ctx_t, shader_t and shade_fn are the ABI (shader_abi.h). */
+
+/* Build a shader by registry name. False if the name is not known — a
+ * built-in, or one a loaded plugin added. */
+bool shader_make(shader_t *out, const char *kind, color_t color,
+ uint8_t amount);
+/* The same, for the shaders that take a number of their own. */
+bool shader_make_p(shader_t *out, const char *kind, color_t color,
+ uint8_t amount, uint16_t param);
+/* Iterate the registry: the i'th name, or NULL past the end. Built-ins first,
+ * then anything plugins added, in load order. */
+const char *shader_kind(size_t i);
+
+/* Load every `*.so` in `dir` that exports a shader plugin, adding what they
+ * declare to the registry. Returns how many shaders were added; a directory
+ * that does not exist is not an error (it is the normal case). Failures are
+ * per file: a library that cannot be opened, does not export the symbol, or
+ * was built against another ABI is reported in `err` and skipped, and the
+ * rest still load.
+ *
+ * Idempotent by path: a file already loaded is not loaded again, so calling
+ * this on a config reload picks up newly added plugins and disturbs nothing.
+ * Nothing is ever unloaded — a shader's function pointer may be sitting in a
+ * live config or on a pane, and dlclose() would turn it into a jump into an
+ * unmapped page. Replacing a plugin therefore needs a new session.
+ *
+ * A plugin is native code with the privileges of the session that loads it.
+ * This is deliberately a directory you own, not a config value pointing
+ * anywhere: the trust decision is "put a file here". */
+size_t shader_load_dir(const char *dir, char *err, size_t errcap);
+
+/* Run `n` shaders in order over `r`, skipping the cells inside `hole` (NULL
+ * for none). `base` supplies everything in the context except the per-cell
+ * position and the size.
+ *
+ * The hole is what makes a pane's *frame* a rect: chrome is the pane's rect
+ * minus its contents, and an effect has to see the whole frame's coordinates
+ * or it could not travel round one — four separate passes over the four sides
+ * would each start counting from zero. Skipped cells are not visited at all,
+ * so a chrome pass cannot touch a cell the content pass owns. */
+void shade_apply(screen_t *s, const shader_t *shaders, size_t n, rect_t r,
+ const rect_t *hole, const shade_ctx_t *base);
+
+#endif /* SLOSH_SHADER_H */
diff --git a/include/shader_abi.h b/include/shader_abi.h
new file mode 100644
index 0000000..8c7a129
--- /dev/null
+++ b/include/shader_abi.h
@@ -0,0 +1,181 @@
+/* The shader ABI: everything a shader needs to see, and nothing else.
+ *
+ * This header exists so a shader can be compiled *outside* this tree — a
+ * plugin includes only this file, with no libghostty-vt, no pty, no layout.
+ * That is also the contract: a shader gets a cell and where it sits, and it
+ * cannot reach anything else, because nothing else is declared here.
+ *
+ * Everything in here is ABI. Changing the layout of these structs breaks
+ * every plugin built against the old one, so SLOSH_SHADER_ABI is bumped
+ * when they change, and a plugin that reports the wrong version (or the wrong
+ * sizes, which catches a plugin built against an edited copy of this file) is
+ * refused at load rather than trusted into a crash.
+ */
+#ifndef SLOSH_SHADER_ABI_H
+#define SLOSH_SHADER_ABI_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+/* 3: shade_ctx_t gained `state_ms`, so an effect can know how long the pane has
+ * been as it is and not only what time it is; struct shader gained
+ * `channels`, which is the pass's business rather than a shader's. */
+#define SLOSH_SHADER_ABI 4
+
+/* Which of a cell's two colours a pass is allowed to keep. Enforced by the
+ * pass, not by the shader: every shader writes whatever it writes, and the
+ * cell's other colour is put back afterwards — so this works for a built-in
+ * and for a loaded one, and no shader has to grow a variant of itself.
+ *
+ * Put back to what it *was*, which for a cell the terminal is drawing in its
+ * own default colour means back to unset. That is the difference between
+ * recolouring a border glyph and painting a dark rectangle behind it. */
+enum {
+ SHADE_FG = 1 << 0,
+ SHADE_BG = 1 << 1,
+ SHADE_BOTH = SHADE_FG | SHADE_BG, /* and 0 means this, so zeroed is normal */
+};
+
+enum {
+ ATTR_BOLD = 1 << 0,
+ ATTR_DIM = 1 << 1,
+ ATTR_ITALIC = 1 << 2,
+ ATTR_UNDERLINE = 1 << 3,
+ ATTR_BLINK = 1 << 4,
+ ATTR_INVERSE = 1 << 5,
+ ATTR_INVISIBLE = 1 << 6,
+ ATTR_STRIKE = 1 << 7,
+};
+
+typedef struct {
+ bool set; /* false = terminal default */
+ uint8_t r, g, b;
+} color_t;
+
+/* A composited cell. `len` bytes of UTF-8; a grapheme cluster may be several
+ * codepoints, hence the buffer rather than a codepoint. width 0 means this is
+ * the tail half of a wide cell and must not be painted.
+ *
+ * A shader may write fg, bg and attrs. It may not write text, len or width:
+ * selection and copy read the terminal rather than the screen, so rewriting
+ * text here would desync them, and changing width would break the invariant
+ * the compositor relies on. */
+typedef struct {
+ char text[16];
+ uint8_t len;
+ uint8_t width;
+ uint16_t attrs;
+ color_t fg, bg;
+} cell_t;
+
+/* Where a cell sits, and what else the effect is allowed to know. */
+typedef struct {
+ uint16_t x, y; /* cell position within the content rect, 0-based */
+ uint16_t cols, rows; /* content size, so an effect can be positional */
+ int64_t now_ms; /* for anything animated */
+ /* How long this pane has been in the state it is in, in milliseconds — the
+ * bell that just rang, the pane that died a moment ago, the one you have not
+ * been in for a while. `now_ms` says what time it is; a one-shot effect needs
+ * to know how long ago something happened, and only the session can say. 0
+ * when there is nothing to date the state from. */
+ int64_t state_ms;
+ bool focused;
+
+ /* Where the cursor is, in the same rect-relative space as x/y. Only ever
+ * set for the pane that owns it, so an effect that follows the cursor does
+ * not chase another pane's. */
+ bool has_cursor;
+ uint16_t cursor_x, cursor_y;
+
+ /* Scrollback, as the viewport sees it: lines hidden above its top edge and
+ * below its bottom one. Both 0 when the pane is showing the present, and
+ * for a pass over anything that is not a pane's contents. */
+ uint32_t above, below;
+
+ /* What "terminal default" means while shading. A cell whose colour is unset
+ * is drawn in whatever the client's terminal calls default, and we never
+ * learn that RGB — but most terminal text is default-coloured, so a shader
+ * that skipped those cells would visibly do nothing. The pass materialises
+ * them to these before running the chain. Configured, because guessing is
+ * the one thing that would be worse. */
+ color_t default_fg, default_bg;
+} shade_ctx_t;
+
+typedef struct shader shader_t;
+typedef void (*shade_fn)(const shader_t *sh, const shade_ctx_t *ctx, cell_t *c);
+
+/* Opaque to a shader: a compiled amount expression, owned by the config. When
+ * one is attached the pass computes `amount` per cell before calling the
+ * shader, so an effect never has to know whether its strength was a number in
+ * a config file or a function of where the cell is. */
+typedef struct expr_prog expr_prog_t;
+
+struct shader {
+ const char *kind; /* registry name, NULL for an empty slot */
+ shade_fn fn;
+ expr_prog_t *amount_expr; /* NULL when `amount` is just a number */
+ color_t color; /* the target colour, for shaders that have one */
+ /* Strength, 0..255; 0 is identity, 255 is fully applied. A cell whose
+ * strength works out to 0 is skipped by the pass rather than handed to the
+ * shader, so a plugin is never called to do nothing -- and a cell the
+ * terminal was drawing in its own default colour stays that way. */
+ uint8_t amount;
+ /* One number whose meaning is the shader's own, because a second parameter
+ * that is a column for one effect and a radius for another is not really
+ * two things:
+ * ruler the column to mark margin first column to dim
+ * zebra rows per band spotlight radius in columns
+ * gradient 0 down, 1 up, 2 right, 3 left
+ */
+ uint16_t param;
+ /* SHADE_FG / SHADE_BG / SHADE_BOTH, and 0 for both. Opaque to a shader, like
+ * `amount_expr` above: it is the pass that keeps a channel, so a shader
+ * cannot get it wrong and does not have to know. */
+ uint8_t channels;
+};
+
+/* ---- plugins ------------------------------------------------------------
+ *
+ * A shared library dropped in the shader directory, exporting one symbol:
+ *
+ * const shader_plugin_t *slosh_shader_plugin(void);
+ *
+ * SLOSH_SHADER_PLUGIN() writes that for you. See
+ * contrib/shader-plugin/ for a complete one.
+ */
+
+typedef struct {
+ const char *name; /* what to call it in the config */
+ shade_fn fn;
+} shader_def_t;
+
+typedef struct {
+ uint32_t abi; /* SLOSH_SHADER_ABI it was built against */
+ uint32_t cell_size; /* the three sizes catch a plugin built against a */
+ uint32_t ctx_size; /* copy of this header that has drifted, which a */
+ uint32_t shader_size; /* version number alone would not */
+ const char *name; /* the bundle's name, for the log line */
+ size_t count;
+ const shader_def_t *shaders;
+} shader_plugin_t;
+
+#define SLOSH_SHADER_PLUGIN_SYM "slosh_shader_plugin"
+
+/* Define the entry point. `defs` is a static array of shader_def_t. */
+#define SLOSH_SHADER_PLUGIN(bundle_name, defs) \
+ const shader_plugin_t *slosh_shader_plugin(void); \
+ const shader_plugin_t *slosh_shader_plugin(void) { \
+ static const shader_plugin_t table = { \
+ .abi = SLOSH_SHADER_ABI, \
+ .cell_size = sizeof(cell_t), \
+ .ctx_size = sizeof(shade_ctx_t), \
+ .shader_size = sizeof(shader_t), \
+ .name = (bundle_name), \
+ .count = sizeof(defs) / sizeof((defs)[0]), \
+ .shaders = (defs), \
+ }; \
+ return &table; \
+ }
+
+#endif /* SLOSH_SHADER_ABI_H */
diff --git a/include/slosh.h b/include/slosh.h
new file mode 100644
index 0000000..834879f
--- /dev/null
+++ b/include/slosh.h
@@ -0,0 +1,282 @@
+/* slosh — common types. See DESIGN.md. */
+#ifndef SLOSH_H
+#define SLOSH_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <sys/types.h>
+
+#include "input.h"
+/* cell_t, color_t and the ATTR_* flags live in the shader ABI, because that is
+ * the one place they are a *contract* with code compiled outside this tree. */
+#include "shader_abi.h"
+
+/* A rectangle of cells. Here rather than in app.h because the layout is not
+ * the only thing that has one: a shader pass is given a rect to run over, and
+ * the frame pass is given the rect it must skip. */
+typedef struct {
+ uint16_t x, y, w, h;
+} rect_t;
+
+/* ---- screen: our composited cell buffer + diff emitter ---------------- */
+
+/* One geometry (DESIGN.md): every painted interactive element records its rect
+ * here as it is painted, and a click is a lookup. Drawing and hit-testing
+ * cannot disagree because there is only one of them. */
+typedef struct {
+ uint16_t x, y, w, h;
+ char action[48];
+} hit_t;
+
+typedef struct {
+ hit_t *items;
+ size_t len, cap;
+} hitlist_t;
+
+void hit_reset(hitlist_t *hl);
+void hit_add(hitlist_t *hl, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
+ const char *action);
+/* The action at a point, or NULL. Later entries win: painted last is on top. */
+const char *hit_test(const hitlist_t *hl, uint16_t x, uint16_t y);
+/* The rect a named action was registered with, or NULL: for a caller that must
+ * grow or paint over an entry some other pass placed. */
+const hit_t *hit_find(const hitlist_t *hl, const char *action);
+
+typedef struct {
+ uint16_t cols, rows;
+ cell_t *cur; /* what we want on screen */
+ cell_t *prev; /* what the terminal last received */
+ bool force_full;
+ hitlist_t hits;
+
+ /* where the real cursor goes after a flush */
+ bool cursor_visible;
+ uint16_t cursor_x, cursor_y;
+ /* what the terminal was last told, so an unchanged frame emits nothing */
+ bool shown_cursor_visible;
+ uint16_t shown_cursor_x, shown_cursor_y;
+
+ /* output accumulator, flushed in one write() */
+ char *out;
+ size_t out_len, out_cap;
+} screen_t;
+
+void screen_init(screen_t *s, uint16_t cols, uint16_t rows);
+void screen_free(screen_t *s);
+void screen_resize(screen_t *s, uint16_t cols, uint16_t rows);
+void screen_clear(screen_t *s);
+cell_t *screen_at(screen_t *s, uint16_t x, uint16_t y);
+void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt,
+ size_t len, color_t fg, color_t bg, uint16_t attrs);
+/* Columns one grapheme cluster occupies, from lib-vt's own width table. */
+uint8_t screen_width_of(const char *txt, size_t len);
+/* Columns a whole string occupies. What chrome must budget with. */
+uint16_t screen_cells(const char *txt);
+/* Write a UTF-8 string; returns the *columns* written, which is not the number
+ * of characters when any of them is wide. */
+uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt,
+ color_t fg, color_t bg, uint16_t attrs);
+/* Diff cur against prev into s->out (the minimal byte stream for a terminal). */
+void screen_render(screen_t *s);
+/* screen_render, then write it to fd. */
+void screen_flush(screen_t *s, int fd);
+/* Plain-text dump of the composited screen; caller frees. (headless tests) */
+char *screen_dump(screen_t *s);
+/* The same screen as JSON: rows of text, style runs, cursor, hit-list. */
+char *screen_dump_json(screen_t *s);
+
+/* ---- paths -------------------------------------------------------------- */
+
+/* Expand a leading `~`, using `buf` when it has to. Returns `path` itself when
+ * there is nothing to do, so `buf` only has to outlive the result's use. */
+const char *path_expand(const char *path, char *buf, size_t cap);
+/* `~`, then a relative path against `base` -- the directory the file that wrote
+ * it lives in, so a layout checked in with a project can say `cwd="."`. A NULL
+ * or empty `base` leaves a relative path alone, which is what it meant before
+ * there was a base to be relative to. */
+const char *path_resolve(const char *path, const char *base, char *buf,
+ size_t cap);
+/* The directory part of a path: the base `path_resolve` wants. `.` when there
+ * is no `/` in it. */
+const char *path_dir(const char *path, char *buf, size_t cap);
+/* The inverse of `path_resolve` for writing one back out: a path under `base`
+ * relative to it, `.` for `base` itself, and anything not under it unchanged.
+ * Points into `path` or a literal, so it needs no buffer. */
+const char *path_relative(const char *path, const char *base);
+/* mkdir -p, for the directory a config or a session socket wants to live in. */
+bool path_mkdirs(const char *dir);
+
+/* ---- pty --------------------------------------------------------------- */
+
+typedef struct {
+ int fd;
+ pid_t pid;
+} pty_t;
+
+/* `cell_w`/`cell_h` are the client's cell size in pixels, so the pty's
+ * winsize carries real pixel dimensions: a program that draws images asks the
+ * tty how big a cell is, and zeroes there mean it cannot size one. */
+int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
+ const char *cwd, uint16_t cell_w, uint16_t cell_h);
+int pty_resize(pty_t *p, uint16_t cols, uint16_t rows, uint16_t cell_w,
+ uint16_t cell_h);
+void pty_close(pty_t *p);
+
+/* ---- pane: a pty + a libghostty-vt terminal ---------------------------- */
+
+typedef struct pane pane_t;
+
+/* An OSC 5577 action button drawn in a pane's frame. */
+typedef struct {
+ char id[33];
+ char label[33];
+} pane_button_t;
+
+/* Verbs pane.c does not handle itself (purpose, and whatever comes later). */
+typedef void (*pane_osc_fn)(pane_t *p, const char *verb, const char *payload,
+ void *ud);
+/* A program in the pane wrote the clipboard (OSC 52). Takes ownership. */
+typedef void (*pane_clip_fn)(pane_t *p, char *text, void *ud);
+/* A program in the pane sent a notification (OSC 9 / OSC 777). */
+typedef void (*pane_notify_fn)(pane_t *p, const char *title, const char *body,
+ void *ud);
+
+pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
+ const char *cwd);
+/* `suspended`: create the pane but run nothing until pane_start(). `label` is
+ * what to show in the meantime (usually the command line). */
+pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows,
+ const char *cwd, bool suspended, const char *label);
+bool pane_suspended(const pane_t *p);
+/* A pane opened to do one thing, rather than one the session is made of: an
+ * editor you popped open, not the dev server a layout declared. It runs a
+ * command like any other, but when that command ends it is finished — so it
+ * keeps no corpse and is not written into a dumped layout. */
+void pane_set_ephemeral(pane_t *p, bool yes);
+bool pane_ephemeral(const pane_t *p);
+const char *pane_label(const pane_t *p);
+/* The pid of the program in the pane, or -1. Used to ask the kernel where a
+ * shell has got to, which is the only honest answer to "what is this pane's
+ * directory" once somebody has typed `cd`. */
+pid_t pane_pid(const pane_t *p);
+/* Where it was started, which is not where it *is*: see pane_pid(). */
+const char *pane_start_cwd(const pane_t *p);
+/* What the pane's terminal is running now, as a command line, or NULL when the
+ * answer is "your shell, at a prompt". Read from the pty's foreground process
+ * group, so it is the job that owns the terminal rather than a guess: `label`
+ * only knows what a layout said, which left a pane you split and typed a command
+ * into with nothing to write down. Shell-quoted, because `command=` is run
+ * through `/bin/sh -c`. */
+const char *pane_foreground(const pane_t *p, char *buf, size_t cap);
+/* How much history the pane keeps: a line count, and a byte ceiling that stops
+ * one wide, heavily styled pane from spending the machine to honour it. 0 lines
+ * is no scrollback; 0 bytes is no ceiling. Both are estimates -- lib-vt prunes a
+ * page at a time, so a pane keeps a little more than it was told, never less --
+ * and lowering either drops history at once, which is what makes this safe to
+ * apply to a running pane. */
+void pane_set_scrollback(pane_t *p, size_t lines, size_t bytes);
+bool pane_start(pane_t *p);
+void pane_free(pane_t *p);
+int pane_fd(const pane_t *p);
+bool pane_alive(const pane_t *p);
+/* How the program ended, for a pane that is no longer alive. `code` is the
+ * exit status, or the signal number when `signaled`. False while it is still
+ * running, and also when the status could not be collected — a pane can be
+ * known to be gone without it being known why. */
+bool pane_exit(const pane_t *p, int *code, bool *signaled);
+/* Run the pane's command again, in the same pty-less pane: same argv, same
+ * cwd, same terminal, so the previous run stays above in the scrollback.
+ * False if the pane is still alive or the spawn failed. */
+bool pane_restart(pane_t *p);
+/* Write a line into the pane's own terminal — the backlog, not the pty (which
+ * by the time this is wanted is closed). It scrolls with the output it
+ * followed, which is the point: it is a thing that happened to this pane. */
+void pane_note(pane_t *p, const char *text, color_t fg);
+/* Read available pty output into the terminal. Returns bytes read, 0 on EOF,
+ * -1 on error (EAGAIN is reported as 0 bytes with alive still true). A pane
+ * that reaches EOF stops being alive here and closes its pty, but keeps its
+ * terminal: a dead pane is still a pane with contents you can read. */
+ssize_t pane_pump(pane_t *p);
+void pane_write(pane_t *p, const void *buf, size_t len);
+/* Re-encode a decoded event against this pane's own negotiated modes. */
+void pane_send_key(pane_t *p, const input_event_t *ev);
+void pane_send_mouse(pane_t *p, const input_event_t *ev);
+void pane_send_paste(pane_t *p, const char *text, size_t len);
+void pane_resize(pane_t *p, uint16_t cols, uint16_t rows);
+/* The client's cell size in pixels. Everything about images depends on it:
+ * without it a placement that asks for its natural size covers zero cells and
+ * is dropped, which is a picture that silently does not appear. */
+void pane_set_cell_px(pane_t *p, uint16_t w, uint16_t h);
+bool pane_dirty(pane_t *p);
+/* Ask for a repaint without having changed a cell: what is drawn *over* the
+ * pane changed, and composing is the only thing that can see that. */
+void pane_touch(pane_t *p);
+/* Scrollback. Negative delta scrolls up (towards older output). */
+void pane_scroll(pane_t *p, int delta);
+void pane_scroll_edge(pane_t *p, bool top);
+bool pane_scrolled(const pane_t *p);
+void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total);
+bool pane_alt_screen(const pane_t *p);
+bool pane_wants_mouse(const pane_t *p);
+const char *pane_title(const pane_t *p);
+/* A BEL arrived and the pane has not been looked at since. */
+bool pane_bell(const pane_t *p);
+void pane_clear_bell(pane_t *p);
+/* The user-assigned name, "" when the program's title is showing through. */
+const char *pane_name(const pane_t *p);
+/* "" clears the name and gives the pane back to the program's title. */
+void pane_set_name(pane_t *p, const char *name);
+const char *pane_status(const pane_t *p);
+size_t pane_buttons(const pane_t *p, const pane_button_t **out);
+void pane_click_button(pane_t *p, const char *id);
+void pane_set_osc_handler(pane_t *p, pane_osc_fn fn, void *ud);
+void pane_set_clipboard_handler(pane_t *p, pane_clip_fn fn, void *ud);
+void pane_set_notify_handler(pane_t *p, pane_notify_fn fn, void *ud);
+
+/* One visible kitty graphics placement, in the pane's viewport coordinates. */
+typedef struct {
+ uint32_t image_id, place_id;
+ uint16_t col, row, cols, rows;
+ /* Where the image starts inside its first cell, in pixels. Sub-cell
+ * positioning: what a program moving something smoothly relies on, and the
+ * difference between motion and a slideshow. */
+ uint32_t x_off, y_off;
+ /* What the program asked to scale into, or 0 for "draw it at its natural
+ * size". The difference matters on the way out: naming a cell count tells
+ * the terminal to *scale* the image into it, and the count a natural
+ * placement happens to cover changes by one as it slides across a cell
+ * boundary — so passing it on makes a moving image change size. */
+ uint16_t req_cols, req_rows;
+ uint32_t px_w, px_h; /* rendered size */
+ uint32_t src_w, src_h; /* the image's own size */
+ /* The part of the image to draw, in pixels. Clipping has to move this:
+ * asking for fewer columns alone *scales* the image into them, it does not
+ * crop it. */
+ uint32_t sx, sy, sw, sh;
+ uint32_t cell_px_w, cell_px_h; /* derived: rendered pixels per cell */
+ int format, compression;
+ uint64_t generation;
+ const uint8_t *data; /* borrowed for the callback only */
+ size_t data_len;
+} pane_gfx_t;
+
+typedef void (*pane_gfx_fn)(pane_t *p, const pane_gfx_t *g, void *ud);
+size_t pane_graphics(pane_t *p, pane_gfx_fn cb, void *ud);
+
+/* Selection, in the pane's viewport coordinates. */
+void pane_select_start(pane_t *p, uint16_t x, uint16_t y);
+void pane_select_extend(pane_t *p, uint16_t x, uint16_t y);
+void pane_select_clear(pane_t *p);
+/* Select the word under a viewport cell, with `seps` naming what a word stops
+ * at. False when there is no word there -- blank, a separator, past the end of
+ * the row -- and then the selection is left alone. */
+bool pane_select_word(pane_t *p, uint16_t x, uint16_t y, const char *seps);
+void pane_select_done(pane_t *p);
+bool pane_selecting(const pane_t *p);
+char *pane_selection_text(pane_t *p);
+/* Compose this pane's viewport into the screen at (x0,y0). Clears dirty. */
+void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0,
+ bool focused);
+
+#endif /* SLOSH_H */