diff options
| author | user <user@clank> | 2026-08-19 13:46:57 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-19 13:46:57 +0200 |
| commit | 42b3c10995eb67778d9dd3aa2fd80624896acd5f (patch) | |
| tree | 751bcb8604967dc8687cf23fbeb1033aa26ca427 /include | |
| parent | slosh: a website that is a page, not a mock session (diff) | |
| download | slosh-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.h | 322 | ||||
| -rw-r--r-- | include/cmd.h | 13 | ||||
| -rw-r--r-- | include/config.h | 510 | ||||
| -rw-r--r-- | include/expr.h | 102 | ||||
| -rw-r--r-- | include/graphics.h | 88 | ||||
| -rw-r--r-- | include/input.h | 94 | ||||
| -rw-r--r-- | include/json.h | 28 | ||||
| -rw-r--r-- | include/jsonval.h | 45 | ||||
| -rw-r--r-- | include/kdl.h | 50 | ||||
| -rw-r--r-- | include/osc5577.h | 56 | ||||
| -rw-r--r-- | include/png.h | 22 | ||||
| -rw-r--r-- | include/project.h | 64 | ||||
| -rw-r--r-- | include/proto.h | 68 | ||||
| -rw-r--r-- | include/server.h | 28 | ||||
| -rw-r--r-- | include/shader.h | 83 | ||||
| -rw-r--r-- | include/shader_abi.h | 181 | ||||
| -rw-r--r-- | include/slosh.h | 282 |
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 */ |
