aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-02 14:57:46 +0200
committeruser <user@clank>2026-09-02 14:57:46 +0200
commitbac4b796f3f729d8d76b9c7c973126eac49872f2 (patch)
tree0d040eb64e433122f982ea45912eba37ab54822a /src
parentwebdemo: version from contrib/version, published by ship (diff)
parentmulti-attach: the config knobs (diff)
downloadslosh-bac4b796f3f729d8d76b9c7c973126eac49872f2.tar.gz
slosh-bac4b796f3f729d8d76b9c7c973126eac49872f2.tar.xz
slosh-bac4b796f3f729d8d76b9c7c973126eac49872f2.zip
Merge branch 'multiplayer': shared multi-client attach
Several terminals can attach to one session at once: the canonical screen becomes a model projected into every client's own size, with per-client diff history, input parsing and kitty graphics state. The active client (newest attach, then last input) sizes the canvas by default; multi_attach and size_follows in the config control both halves of the policy.
Diffstat (limited to 'src')
-rw-r--r--src/app.c24
-rw-r--r--src/app_draw.c119
-rw-r--r--src/config.c27
-rw-r--r--src/main.c11
-rw-r--r--src/screen.c76
-rw-r--r--src/server.c493
6 files changed, 631 insertions, 119 deletions
diff --git a/src/app.c b/src/app.c
index d931774..9c9829b 100644
--- a/src/app.c
+++ b/src/app.c
@@ -88,6 +88,16 @@ size_t app_config_files(const char **out, size_t max) {
return config_files(&CFG, out, max);
}
+bool app_cfg_multi_attach(void) {
+ ensure_config();
+ return CFG.multi_attach;
+}
+
+int app_cfg_size_follows(void) {
+ ensure_config();
+ return CFG.size_follows;
+}
+
/* ---- tree --------------------------------------------------------------- */
#include "app_internal.h"
@@ -871,6 +881,20 @@ bool app_should_quit(const app_t *a) { return a->quit || a->ntabs == 0; }
bool app_detach_requested(const app_t *a) { return a->detach; }
void app_clear_detach(app_t *a) { a->detach = false; }
+void app_cancel_client_pointer(app_t *a) {
+ if (a->drag.kind == DRAG_SELECT) {
+ node_t *n = pane_by_id(a, a->drag.src);
+ if (n) pane_select_done(n->pane);
+ }
+ memset(&a->drag, 0, sizeof a->drag);
+ a->ptr_valid = false;
+}
+
+void app_cancel_client_interaction(app_t *a) {
+ a->prefix = false;
+ app_cancel_client_pointer(a);
+}
+
/* ---- walking ------------------------------------------------------------ */
typedef void (*leaf_fn)(node_t *, void *);
diff --git a/src/app_draw.c b/src/app_draw.c
index 85f20fd..2955c11 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -2219,7 +2219,9 @@ void draw_floats(app_t *a, screen_t *s) {
struct gfx_ctx {
app_t *a;
+ graphics_t *out;
node_t *leaf;
+ rect_t viewport;
/* The float rects drawn over this leaf's cells: every visible float for a
* tiled pane, the higher-raised ones for a float. A placement is clipped
* against them below, because kitty images are drawn by the client's
@@ -2229,6 +2231,84 @@ struct gfx_ctx {
size_t nocc;
};
+/* Crop one canonical placement to an attached client's viewport, then move it
+ * into that client's coordinates. Kitty's c=/r= scale and source rectangle
+ * make this more than clipping four integers: losing cells must lose the
+ * corresponding source pixels, or the image is squeezed rather than cropped. */
+static void gfx_place_view(graphics_t *out, gfx_req_t r, rect_t v) {
+ uint32_t x1 = (uint32_t)r.col + r.cols;
+ uint32_t y1 = (uint32_t)r.row + r.rows;
+ uint32_t vx1 = (uint32_t)v.x + v.w;
+ uint32_t vy1 = (uint32_t)v.y + v.h;
+ if (x1 <= v.x || r.col >= vx1 || y1 <= v.y || r.row >= vy1) return;
+
+ if (r.col < v.x) {
+ uint16_t old = r.cols, cut = (uint16_t)(v.x - r.col);
+ if (r.scale_cols) {
+ uint32_t drop = old ? (uint32_t)((uint64_t)r.sw * cut / old) : 0;
+ r.sx += drop;
+ r.sw -= drop;
+ } else {
+ uint32_t px = (uint32_t)cut * r.cell_px_w;
+ px = px > r.x_off ? px - r.x_off : 0;
+ r.x_off = 0;
+ uint32_t drop = px < r.sw ? px : r.sw;
+ r.sx += drop;
+ r.sw -= drop;
+ }
+ r.col = v.x;
+ r.cols = (uint16_t)(old - cut);
+ }
+ if (r.row < v.y) {
+ uint16_t old = r.rows, cut = (uint16_t)(v.y - r.row);
+ if (r.scale_rows) {
+ uint32_t drop = old ? (uint32_t)((uint64_t)r.sh * cut / old) : 0;
+ r.sy += drop;
+ r.sh -= drop;
+ } else {
+ uint32_t px = (uint32_t)cut * r.cell_px_h;
+ px = px > r.y_off ? px - r.y_off : 0;
+ r.y_off = 0;
+ uint32_t drop = px < r.sh ? px : r.sh;
+ r.sy += drop;
+ r.sh -= drop;
+ }
+ r.row = v.y;
+ r.rows = (uint16_t)(old - cut);
+ }
+ if ((uint32_t)r.col + r.cols > vx1) {
+ uint16_t old = r.cols, keep = (uint16_t)(vx1 - r.col);
+ if (r.scale_cols)
+ r.sw = old ? (uint32_t)((uint64_t)r.sw * keep / old) : 0;
+ else {
+ uint32_t px = (uint32_t)keep * r.cell_px_w;
+ px = px > r.x_off ? px - r.x_off : 0;
+ if (r.sw > px) r.sw = px;
+ }
+ r.cols = keep;
+ }
+ if ((uint32_t)r.row + r.rows > vy1) {
+ uint16_t old = r.rows, keep = (uint16_t)(vy1 - r.row);
+ if (r.scale_rows)
+ r.sh = old ? (uint32_t)((uint64_t)r.sh * keep / old) : 0;
+ else {
+ uint32_t px = (uint32_t)keep * r.cell_px_h;
+ px = px > r.y_off ? px - r.y_off : 0;
+ if (r.sh > px) r.sh = px;
+ }
+ r.rows = keep;
+ }
+ /* Keep the old emitter's representability rule: a very small source scaled
+ * over many cells can round a cropped source extent to zero, but it is still
+ * a live placement and must not disappear from the model. */
+ if (!r.cols || !r.rows) return;
+ if (r.scale_cols) r.scale_cols = r.cols;
+ if (r.scale_rows) r.scale_rows = r.rows;
+ r.col = (uint16_t)(r.col - v.x);
+ r.row = (uint16_t)(r.row - v.y);
+ gfx_place(out, &r);
+}
+
static void gfx_from_pane(pane_t *p, const pane_gfx_t *g, void *ud) {
struct gfx_ctx *c = ud;
node_t *leaf = c->leaf;
@@ -2360,9 +2440,9 @@ static void gfx_from_pane(pane_t *p, const pane_gfx_t *g, void *ud) {
if (!cols || !rows || !sw || !sh) return;
}
- gfx_place(
- c->a->gfx,
- &(gfx_req_t){
+ gfx_place_view(
+ c->out,
+ (gfx_req_t){
.pane = leaf->id,
.src_id = g->image_id,
.gen = g->generation,
@@ -2385,17 +2465,22 @@ static void gfx_from_pane(pane_t *p, const pane_gfx_t *g, void *ud) {
.sh = sh,
.px_w = g->src_w,
.px_h = g->src_h,
+ .cell_px_w = g->cell_px_w,
+ .cell_px_h = g->cell_px_h,
.format = g->format,
.compression = g->compression,
.data = g->data,
.data_len = g->data_len,
- });
+ },
+ c->viewport);
}
/* One frame's worth of "what covers what": the visible floats, so every
* leaf's placements can be clipped by the ones above it. */
struct gfx_walk {
app_t *a;
+ graphics_t *out;
+ rect_t viewport;
rect_t occ[64];
uint32_t raised[64];
size_t n;
@@ -2411,16 +2496,24 @@ static void gfx_leaf_cb(node_t *n, void *ud) {
size_t k = 0;
for (size_t i = 0; i < w->n; i++)
if (!n->floating || w->raised[i] > n->raised) occ[k++] = w->occ[i];
- struct gfx_ctx ctx = {w->a, n, occ, k};
+ struct gfx_ctx ctx = {.a = w->a,
+ .out = w->out,
+ .leaf = n,
+ .viewport = w->viewport,
+ .occ = occ,
+ .nocc = k};
pane_graphics(n->pane, gfx_from_pane, &ctx);
}
/* Walk the visible tab's panes and produce the bytes the client's terminal
* needs this frame. Borrowed until the next call. */
-const char *app_graphics(app_t *a, size_t *len) {
- gfx_begin(a->gfx);
+const char *app_graphics_view(app_t *a, graphics_t *state, uint16_t x,
+ uint16_t y, uint16_t cols, uint16_t rows,
+ size_t *len) {
+ gfx_begin(state);
if (a->ntabs && cur(a)->root) {
- struct gfx_walk w = {.a = a};
+ struct gfx_walk w = {
+ .a = a, .out = state, .viewport = (rect_t){x, y, cols, rows}};
node_t *fl[64];
size_t nf = collect_floating(cur(a)->root, fl, 64, 0);
for (size_t i = 0; i < nf && w.n < 64; i++) {
@@ -2430,7 +2523,11 @@ const char *app_graphics(app_t *a, size_t *len) {
}
walk(cur(a)->root, gfx_leaf_cb, &w);
}
- return gfx_flush(a->gfx, len);
+ return gfx_flush(state, len);
+}
+
+const char *app_graphics(app_t *a, size_t *len) {
+ return app_graphics_view(a, a->gfx, 0, 0, a->cols, a->rows, len);
}
void app_graphics_reset(app_t *a) { gfx_reset(a->gfx); }
@@ -2439,6 +2536,10 @@ void app_graphics_commit(app_t *a, bool delivered) {
gfx_commit(a->gfx, delivered);
}
+void app_graphics_view_commit(graphics_t *state, bool delivered) {
+ gfx_commit(state, delivered);
+}
+
char *app_graphics_json(app_t *a) {
size_t len = 0;
app_graphics(a, &len); /* refresh the model; the bytes are the caller's job */
diff --git a/src/config.c b/src/config.c
index 0c7af63..bfbfdf7 100644
--- a/src/config.c
+++ b/src/config.c
@@ -918,6 +918,8 @@ void config_defaults(config_t *c) {
* than as another row of it. */
c->status_pad = 4;
c->focus_follows_mouse = true;
+ c->multi_attach = true;
+ c->size_follows = SIZE_FOLLOWS_ACTIVE;
c->in_band_shaders = false;
/* Windows has no VEOF for the line discipline to act on, so ^D there is a
* key that does nothing until we make it mean something. POSIX already has
@@ -1607,6 +1609,14 @@ char *config_render(const config_t *c) {
cb_add(&b, "\n// ---- behaviour ----\n");
cb_add(&b, "focus_follows_mouse %s\n", yesno(c->focus_follows_mouse));
+ cb_add(&b,
+ "multi_attach %s // several terminals at once; off: a new "
+ "client displaces the old\n",
+ yesno(c->multi_attach));
+ cb_add(&b, "size_follows \"%s\" // or \"smallest\" / \"largest\"\n",
+ c->size_follows == SIZE_FOLLOWS_SMALLEST ? "smallest"
+ : c->size_follows == SIZE_FOLLOWS_LARGEST ? "largest"
+ : "active");
cb_add(&b, "in_band_shaders %s\n", yesno(c->in_band_shaders));
cb_add(&b,
"ctrl_d_exits %s // ^D at an idle prompt exits the shell; "
@@ -1861,6 +1871,7 @@ static const char *const KNOWN_TOP[] = {
"keys",
"min_mark",
"min_pane",
+ "multi_attach",
"min_split",
"modal_scrim",
"newtab_mark",
@@ -1875,6 +1886,7 @@ static const char *const KNOWN_TOP[] = {
"select_scroll_ms",
"shaders",
"shell",
+ "size_follows",
"splash_ms",
"states",
"status_bar",
@@ -2081,6 +2093,21 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
(uint16_t)kdl_arg_int(kdl_child(root, "status_pad"), 0, c->status_pad);
c->focus_follows_mouse = kdl_arg_bool(kdl_child(root, "focus_follows_mouse"),
0, c->focus_follows_mouse);
+ c->multi_attach =
+ kdl_arg_bool(kdl_child(root, "multi_attach"), 0, c->multi_attach);
+ const char *sizef = kdl_arg(kdl_child(root, "size_follows"), 0, NULL);
+ if (sizef) {
+ if (strcmp(sizef, "active") == 0)
+ c->size_follows = SIZE_FOLLOWS_ACTIVE;
+ else if (strcmp(sizef, "smallest") == 0)
+ c->size_follows = SIZE_FOLLOWS_SMALLEST;
+ else if (strcmp(sizef, "largest") == 0)
+ c->size_follows = SIZE_FOLLOWS_LARGEST;
+ else
+ complain(c, err, errcap, 0,
+ "size_follows wants active, smallest or largest, not \"%s\"",
+ sizef);
+ }
c->in_band_shaders =
kdl_arg_bool(kdl_child(root, "in_band_shaders"), 0, c->in_band_shaders);
c->ctrl_d_exits =
diff --git a/src/main.c b/src/main.c
index e3e621e..798b0a0 100644
--- a/src/main.c
+++ b/src/main.c
@@ -249,14 +249,13 @@ int main(int argc, char **argv) {
if (isatty(STDOUT_FILENO)) term_size(&cols, &rows);
if (server) return server_run(name, cmd_argv, cols, rows, layout, watch);
- /* Not to the session this process is already inside. A new client
- * displaces the current display (EXIT_REPLACED), so `slosh` typed into a
- * pane of "main" attaches to main, displaces the very client showing that
- * pane, and the user watches their own session detach from inside it.
+ /* Not to the session this process is already inside. `slosh` typed into a
+ * pane of "main" would attach that pane's terminal back to the session which
+ * is drawing it: a recursive view whose own output becomes its next input.
* SLOSH_SESSION names the pane's own session; asking for any *other* name
* is deliberate nesting and none of our business. The one legitimate shape
* this refuses — a fresh terminal launched from a pane, inheriting the
- * variables, attaching to take over as the display — gets its escape hatch
+ * variables, deliberately making that recursive attachment — gets its escape hatch
* named in the message rather than a flag: it is exactly what unsetting
* the variable means. */
const char *inside = getenv("SLOSH") ? getenv("SLOSH_SESSION") : NULL;
@@ -264,7 +263,7 @@ int main(int argc, char **argv) {
fprintf(stderr,
"slosh: this pane is already inside session %s\n"
" (C-a d detaches; `slosh -s NAME` nests another session;\n"
- " unset SLOSH_SESSION to attach anyway, displacing this view)\n",
+ " unset SLOSH_SESSION to attach recursively anyway)\n",
name);
return 1;
}
diff --git a/src/screen.c b/src/screen.c
index 7bd474a..b768162 100644
--- a/src/screen.c
+++ b/src/screen.c
@@ -135,6 +135,82 @@ void screen_clear(screen_t *s) {
memset(s->cur_link, 0, n * sizeof(uint16_t));
}
+void screen_project(screen_t *dst, const screen_t *src, uint16_t x0,
+ uint16_t y0) {
+ screen_clear(dst);
+ hit_reset(&dst->hits);
+ dst->cursor_visible = false;
+
+ for (uint16_t y = 0; y < dst->rows && (uint32_t)y0 + y < src->rows; y++) {
+ uint16_t sy = (uint16_t)(y0 + y);
+ for (uint16_t x = 0; x < dst->cols && (uint32_t)x0 + x < src->cols; x++) {
+ uint16_t sx = (uint16_t)(x0 + x);
+ size_t si = (size_t)sy * src->cols + sx;
+ size_t di = (size_t)y * dst->cols + x;
+ const cell_t *c = &src->cur[si];
+
+ /* A viewport cannot display half a wide grapheme. A tail at its left
+ * edge and a head at its right edge both remain filler rather than
+ * relying on terminal-dependent wrapping or replacement glyphs. */
+ if (c->width == 0) continue;
+ if (c->width == 2 &&
+ ((uint32_t)sx + 1 >= src->cols || (uint32_t)x + 1 >= dst->cols))
+ continue;
+
+ dst->cur[di] = *c;
+ uint16_t link = src->cur_link[si];
+ if (link && link <= src->nlinks) {
+ const char *uri = src->links[link - 1];
+ dst->cur_link[di] = screen_link_id(dst, uri, strlen(uri));
+ }
+ if (c->width == 2) {
+ dst->cur[di + 1] = src->cur[si + 1];
+ uint16_t tail_link = src->cur_link[si + 1];
+ if (tail_link && tail_link <= src->nlinks) {
+ const char *uri = src->links[tail_link - 1];
+ dst->cur_link[di + 1] = screen_link_id(dst, uri, strlen(uri));
+ }
+ x++;
+ }
+ }
+ }
+
+ if (src->cursor_visible && src->cursor_x >= x0 && src->cursor_y >= y0 &&
+ (uint32_t)src->cursor_x < (uint32_t)x0 + dst->cols &&
+ (uint32_t)src->cursor_y < (uint32_t)y0 + dst->rows) {
+ dst->cursor_visible = true;
+ dst->cursor_x = (uint16_t)(src->cursor_x - x0);
+ dst->cursor_y = (uint16_t)(src->cursor_y - y0);
+ }
+}
+
+void screen_follow_cursor(const screen_t *src, uint16_t cols, uint16_t rows,
+ uint16_t *x, uint16_t *y) {
+ uint16_t vx = x ? *x : 0, vy = y ? *y : 0;
+ uint16_t max_x = src->cols > cols ? (uint16_t)(src->cols - cols) : 0;
+ uint16_t max_y = src->rows > rows ? (uint16_t)(src->rows - rows) : 0;
+ if (!max_x)
+ vx = 0;
+ else if (src->cursor_visible) {
+ if (src->cursor_x < vx)
+ vx = src->cursor_x;
+ else if ((uint32_t)src->cursor_x >= (uint32_t)vx + cols)
+ vx = (uint16_t)(src->cursor_x - cols + 1);
+ }
+ if (!max_y)
+ vy = 0;
+ else if (src->cursor_visible) {
+ if (src->cursor_y < vy)
+ vy = src->cursor_y;
+ else if ((uint32_t)src->cursor_y >= (uint32_t)vy + rows)
+ vy = (uint16_t)(src->cursor_y - rows + 1);
+ }
+ if (vx > max_x) vx = max_x;
+ if (vy > max_y) vy = max_y;
+ if (x) *x = vx;
+ if (y) *y = vy;
+}
+
uint16_t screen_link_id(screen_t *s, const char *uri, size_t len) {
if (!uri || !len || len > 1024) return 0;
/* A URI is emitted verbatim inside an OSC, so a control byte in one is a
diff --git a/src/server.c b/src/server.c
index 96e79cd..7443648 100644
--- a/src/server.c
+++ b/src/server.c
@@ -1,6 +1,6 @@
-/* The server: owns the panes and the layout, renders, and talks to at most one
- * attached client (D10). It outlives every client, which is the entire point —
- * agents keep running while you are gone. */
+/* The server: owns the panes and shared layout, renders once, and projects that
+ * screen into every attached client. It outlives all of them, which is the
+ * entire point — agents keep running while you are gone. */
#define _GNU_SOURCE
#include "server.h"
@@ -87,6 +87,14 @@ typedef struct {
int fd;
msg_reader_t reader;
bool display;
+ uint16_t cols, rows, cell_w, cell_h;
+ uint16_t view_x, view_y, shown_cols, shown_rows;
+ uint64_t activity;
+ input_parser_t *in;
+ int64_t esc_due;
+ screen_t view;
+ bool view_init;
+ graphics_t *gfx;
uint8_t *out; /* queued bytes, already framed */
size_t out_len, out_off, out_cap;
} conn_t;
@@ -94,22 +102,41 @@ typedef struct {
typedef struct {
app_t *app;
screen_t screen;
- input_parser_t *in;
+ input_parser_t *cmd_in;
conn_t conns[MAX_CONNS];
size_t nconns;
+ int active_fd;
+ uint64_t activity_seq;
+ uint16_t fallback_cell_w, fallback_cell_h;
+ bool pending_paint;
+ int64_t next_frame;
} server_t;
-static conn_t *display_conn(server_t *s) {
+static conn_t *active_conn(server_t *s) {
for (size_t i = 0; i < s->nconns; i++)
- if (s->conns[i].display) return &s->conns[i];
+ if (s->conns[i].display && s->conns[i].fd == s->active_fd)
+ return &s->conns[i];
return NULL;
}
-static void conn_close(server_t *s, size_t i) {
+static size_t display_count(const server_t *s) {
+ size_t n = 0;
+ for (size_t i = 0; i < s->nconns; i++)
+ if (s->conns[i].display) n++;
+ return n;
+}
+
+static bool conn_close(server_t *s, size_t i) {
+ bool active = s->conns[i].fd == s->active_fd;
close(s->conns[i].fd);
msg_reader_free(&s->conns[i].reader);
+ input_free(s->conns[i].in);
+ if (s->conns[i].view_init) screen_free(&s->conns[i].view);
+ gfx_free(s->conns[i].gfx);
free(s->conns[i].out);
s->conns[i] = s->conns[--s->nconns];
+ if (active) s->active_fd = -1;
+ return active;
}
/* Push what is queued. Returns false when the connection is finished with --
@@ -171,13 +198,14 @@ static bool conn_send(conn_t *c, uint8_t type, const void *data, size_t len) {
return conn_flush(c);
}
-static void conn_drop(server_t *s, size_t i, uint8_t reason) {
+static bool conn_drop(server_t *s, size_t i, uint8_t reason) {
conn_send(&s->conns[i], MSG_EXIT, &reason, 1);
- conn_close(s, i);
+ return conn_close(s, i);
}
-static void srv_event(const input_event_t *ev, void *ud) {
- app_event(((server_t *)ud)->app, ev);
+static void request_paint(server_t *s, bool immediate) {
+ s->pending_paint = true;
+ if (immediate || !s->next_frame) s->next_frame = now_ms();
}
static int listen_socket(const char *path) {
@@ -229,26 +257,25 @@ static char *b64(const char *in, size_t len, size_t *out_len) {
* a frame here silently was how a scrolled-away screenshot stayed parked on
* the screen: its deletion left with the dropped bytes and was never owed
* again. */
-static bool push_graphics(server_t *s) {
- conn_t *c = display_conn(s);
- if (!c) return true;
+static bool push_graphics(server_t *s, conn_t *c) {
size_t len = 0;
- const char *bytes = app_graphics(s->app, &len);
+ const char *bytes = app_graphics_view(s->app, c->gfx, c->view_x, c->view_y,
+ c->cols, c->rows, &len);
if (!len) {
- app_graphics_commit(s->app, true);
+ app_graphics_view_commit(c->gfx, true);
return true;
}
bool ok = conn_send(c, MSG_OUTPUT, bytes, len);
- app_graphics_commit(s->app, ok);
+ app_graphics_view_commit(c->gfx, ok);
return ok;
}
/* The clipboard lives on the client's machine, not ours, so a copy travels as
- * OSC 52 for the outer terminal to honour. */
-static void push_clipboard(server_t *s) {
+ * OSC 52 for the outer terminal to honour. Input-driven copies name their
+ * originating connection; asynchronous pane copies use the active one. */
+static void push_clipboard_to(server_t *s, conn_t *c) {
char *text = app_take_clipboard(s->app);
if (!text) return;
- conn_t *c = display_conn(s);
if (c) {
size_t b64len = 0;
char *enc = b64(text, strlen(text), &b64len);
@@ -262,31 +289,220 @@ static void push_clipboard(server_t *s) {
free(text);
}
-/* Send whatever the last compose produced to the display client. */
-/* Returns whether the client has the frame (or there is none to owe it to).
+static void push_clipboard(server_t *s) {
+ push_clipboard_to(s, active_conn(s));
+}
+
+/* Send whatever the last compose produced to one display client. */
+/* Returns whether this client has the frame.
* A refused send is a full outbox, not a dead client: the diff that was not
* queued is lost, so the client's cells are unknown from here on and the next
* attempt must be a full repaint. The caller keeps the paint pending, and the
* outbox drains on POLLOUT between frames; a client that truly went away
* shows up as a write error there, not here. */
-static bool push_frame(server_t *s) {
- conn_t *c = display_conn(s);
- if (!c) return true;
- screen_render(&s->screen);
- if (!s->screen.out_len) return true;
- if (!conn_send(c, MSG_OUTPUT, s->screen.out, s->screen.out_len)) {
- s->screen.force_full = true;
+static void update_viewport(conn_t *c, const screen_t *src) {
+ screen_follow_cursor(src, c->cols, c->rows, &c->view_x, &c->view_y);
+ c->shown_cols = src->cols > c->view_x ? (uint16_t)(src->cols - c->view_x) : 0;
+ c->shown_rows = src->rows > c->view_y ? (uint16_t)(src->rows - c->view_y) : 0;
+ if (c->shown_cols > c->cols) c->shown_cols = c->cols;
+ if (c->shown_rows > c->rows) c->shown_rows = c->rows;
+}
+
+static bool push_frame(server_t *s, conn_t *c) {
+ uint16_t old_x = c->view_x, old_y = c->view_y;
+ uint16_t old_cols = c->shown_cols, old_rows = c->shown_rows;
+ update_viewport(c, &s->screen);
+ screen_project(&c->view, &s->screen, c->view_x, c->view_y);
+ screen_render(&c->view);
+ if (!c->view.out_len) return true;
+ if (!conn_send(c, MSG_OUTPUT, c->view.out, c->view.out_len)) {
+ /* The projection never entered this client's stream. Keep mouse mapping
+ * tied to the last viewport we did enqueue, then retry this one in full. */
+ c->view_x = old_x;
+ c->view_y = old_y;
+ c->shown_cols = old_cols;
+ c->shown_rows = old_rows;
+ c->view.force_full = true;
return false;
}
return true;
}
-static void drop_display(server_t *s, uint8_t reason) {
+static bool push_clients(server_t *s) {
+ /* cmd_exec(), config reloads and canonical resizes request a terminal reset
+ * on the canonical screen. It is never rendered directly now, so carry that
+ * request into every independently diffed client view and consume it here.
+ * A newly attached view starts force_full on its own, including when there
+ * were no displays around to receive an earlier request. */
+ if (s->screen.force_full) {
+ for (size_t i = 0; i < s->nconns; i++)
+ if (s->conns[i].display) s->conns[i].view.force_full = true;
+ s->screen.force_full = false;
+ }
+
+ bool ok = true;
+ for (size_t i = 0; i < s->nconns; i++) {
+ conn_t *c = &s->conns[i];
+ if (!c->display) continue;
+ bool frame_ok = push_frame(s, c);
+ bool gfx_ok = push_graphics(s, c);
+ if (!(frame_ok && gfx_ok)) ok = false;
+ }
+ return ok;
+}
+
+/* Size the canonical screen by policy (size_follows). "active" takes the
+ * active client's size; "smallest"/"largest" fold every attached display, so
+ * any attach, resize or departure can move the canvas. The active client
+ * still supplies the cell pixel size either way: graphics go to terminals,
+ * and the active one is the best guess at what a cell is worth. */
+static void apply_canvas(server_t *s) {
+ conn_t *a = active_conn(s);
+ int policy = app_cfg_size_follows();
+ uint16_t cols = 0, rows = 0;
+ if (policy == SIZE_FOLLOWS_ACTIVE) {
+ if (a) {
+ cols = a->cols;
+ rows = a->rows;
+ }
+ } else {
+ for (size_t i = 0; i < s->nconns; i++) {
+ conn_t *c = &s->conns[i];
+ if (!c->display || !c->cols || !c->rows) continue;
+ if (!cols) {
+ cols = c->cols;
+ rows = c->rows;
+ } else if (policy == SIZE_FOLLOWS_SMALLEST) {
+ if (c->cols < cols) cols = c->cols;
+ if (c->rows < rows) rows = c->rows;
+ } else {
+ if (c->cols > cols) cols = c->cols;
+ if (c->rows > rows) rows = c->rows;
+ }
+ }
+ }
+ if (!cols || !rows) { /* nobody is looking: leave the canvas standing */
+ request_paint(s, true);
+ return;
+ }
+ bool changed = cols != s->screen.cols || rows != s->screen.rows;
+ uint16_t old_w = 0, old_h = 0;
+ app_cell_px(s->app, &old_w, &old_h);
+ uint16_t cell_w = a && a->cell_w ? a->cell_w : s->fallback_cell_w;
+ uint16_t cell_h = a && a->cell_h ? a->cell_h : s->fallback_cell_h;
+ bool px_changed = cell_w != old_w || cell_h != old_h;
+ if (changed) {
+ screen_resize(&s->screen, cols, rows);
+ app_resize(s->app, cols, rows);
+ }
+ if (px_changed) app_set_cell_px(s->app, cell_w, cell_h);
+ if (changed || px_changed) app_compose(s->app, &s->screen);
+ request_paint(s, true);
+}
+
+static void make_active(server_t *s, conn_t *c, bool new_activity) {
+ if (s->active_fd != c->fd) app_cancel_client_interaction(s->app);
+ s->active_fd = c->fd;
+ if (new_activity) c->activity = ++s->activity_seq;
+ apply_canvas(s);
+}
+
+static void promote_latest(server_t *s) {
+ conn_t *latest = NULL;
+ for (size_t i = 0; i < s->nconns; i++) {
+ conn_t *c = &s->conns[i];
+ if (c->display && (!latest || c->activity > latest->activity)) latest = c;
+ }
+ app_cancel_client_interaction(s->app);
+ if (!latest) {
+ s->active_fd = -1;
+ request_paint(s, true);
+ return;
+ }
+ s->active_fd = latest->fd;
+ apply_canvas(s);
+}
+
+/* A non-active departure changes nothing under "active", but under
+ * "smallest"/"largest" the canvas may have been sized by the very client
+ * that just left. */
+static void close_conn(server_t *s, size_t i) {
+ if (conn_close(s, i))
+ promote_latest(s);
+ else
+ apply_canvas(s);
+}
+
+static void drop_conn(server_t *s, size_t i, uint8_t reason) {
+ if (conn_drop(s, i, reason))
+ promote_latest(s);
+ else
+ apply_canvas(s);
+}
+
+static void drop_fd(server_t *s, int fd, uint8_t reason) {
for (size_t i = 0; i < s->nconns; i++)
- if (s->conns[i].display) {
- conn_drop(s, i, reason);
+ if (s->conns[i].fd == fd) {
+ drop_conn(s, i, reason);
+ return;
+ }
+}
+
+typedef struct {
+ server_t *server;
+ conn_t *conn;
+ bool activity;
+} client_input_t;
+
+static void client_event(const input_event_t *ev, void *ud) {
+ client_input_t *ctx = ud;
+ server_t *s = ctx->server;
+ conn_t *c = ctx->conn;
+ bool user =
+ ev->kind == EV_KEY || ev->kind == EV_MOUSE || ev->kind == EV_PASTE;
+ if (user && !ctx->activity) {
+ if (s->active_fd != c->fd) app_cancel_client_interaction(s->app);
+ s->active_fd = c->fd;
+ c->activity = ++s->activity_seq;
+ ctx->activity = true;
+ }
+
+ if (ev->kind == EV_MOUSE) {
+ /* The event names the frame this client actually saw. Filler has no target;
+ * a cropped cell maps back into the canonical screen before hit-testing. */
+ if (ev->mx >= c->shown_cols || ev->my >= c->shown_rows) {
+ /* Filler has no hit target, but a release there still has to terminate
+ * a drag that began over real content. Otherwise the shared app keeps a
+ * button logically held until somebody happens to press a key. */
+ if (ev->maction == MOUSE_RELEASE) app_cancel_client_pointer(s->app);
return;
}
+ input_event_t mapped = *ev;
+ mapped.mx = (uint16_t)(mapped.mx + c->view_x);
+ mapped.my = (uint16_t)(mapped.my + c->view_y);
+ app_event(s->app, &mapped);
+ return;
+ }
+ app_event(s->app, ev);
+}
+
+static bool feed_client(server_t *s, conn_t *c, const uint8_t *data, size_t len,
+ bool timeout) {
+ client_input_t ctx = {.server = s, .conn = c};
+ if (timeout)
+ input_timeout(c->in, client_event, &ctx);
+ else
+ input_feed(c->in, data, len, client_event, &ctx);
+ if (ctx.activity) apply_canvas(s);
+ if (input_pending(c->in)) c->esc_due = now_ms() + ESC_MS;
+ return ctx.activity;
+}
+
+static void queue_detach(int fds[MAX_CONNS], size_t *n, int fd) {
+ if (fd < 0) return;
+ for (size_t i = 0; i < *n; i++)
+ if (fds[i] == fd) return;
+ if (*n < MAX_CONNS) fds[(*n)++] = fd;
}
/* What the config watcher is watching: a directory per config file, and the
@@ -501,10 +717,11 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
signal(SIGCHLD, SIG_DFL);
#endif
- server_t s = {0};
+ server_t s = {.active_fd = -1};
s.app = app_new(argv, cols, rows);
if (s.app) app_set_session(s.app, name);
if (!s.app) return 1;
+ app_cell_px(s.app, &s.fallback_cell_w, &s.fallback_cell_h);
if (layout) {
char err[256] = {0};
if (!app_apply_layout_file(s.app, layout, true, err, sizeof err))
@@ -512,7 +729,7 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
}
app_resize(s.app, cols, rows);
screen_init(&s.screen, cols, rows);
- s.in = input_new();
+ s.cmd_in = input_new();
app_compose(s.app, &s.screen); /* a click resolves against a painted frame */
@@ -552,9 +769,8 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
watch_config(&watches);
}
- bool pending_paint = true;
- int64_t next_frame = now_ms();
- int64_t esc_due = 0;
+ s.pending_paint = true;
+ s.next_frame = now_ms();
while (!g_stop && !app_should_quit(s.app)) {
int fds[MAX_PANES];
@@ -576,15 +792,16 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
if (inofd >= 0) pfds[n++] = (struct pollfd){.fd = inofd, .events = POLLIN};
int timeout = -1;
- if (pending_paint) {
- int64_t due = next_frame - now_ms();
+ if (s.pending_paint) {
+ int64_t due = s.next_frame - now_ms();
timeout = due <= 0 ? 0 : (int)due;
}
- if (input_pending(s.in)) {
- int64_t due = esc_due - now_ms();
- int t = due <= 0 ? 0 : (int)due;
- if (timeout < 0 || t < timeout) timeout = t;
- }
+ for (size_t i = 0; i < s.nconns; i++)
+ if (s.conns[i].display && input_pending(s.conns[i].in)) {
+ int64_t due = s.conns[i].esc_due - now_ms();
+ int t = due <= 0 ? 0 : (int)due;
+ if (timeout < 0 || t < timeout) timeout = t;
+ }
/* some things happen without an event: a toast expiring, a hover guide
* arming under a pointer that is deliberately not moving */
if (reload_due >= 0) {
@@ -595,7 +812,7 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
int self_due = app_next_deadline_ms(s.app);
if (self_due >= 0 && (timeout < 0 || self_due < timeout)) {
timeout = self_due;
- pending_paint = true;
+ s.pending_paint = true;
}
int r = poll(pfds, (nfds_t)n, timeout);
@@ -618,6 +835,8 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
}
}
+ int detach_fds[MAX_CONNS];
+ size_t ndetach = 0;
for (size_t ci = 0; ci < s.nconns;) {
struct pollfd *pf = &pfds[conn_slot + ci];
if (pf->fd != s.conns[ci].fd) {
@@ -626,7 +845,7 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
}
if (pf->revents & POLLOUT) {
if (!conn_flush(&s.conns[ci])) {
- conn_close(&s,
+ close_conn(&s,
ci); /* gone, or too far behind to still be a display */
continue;
}
@@ -638,7 +857,7 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
uint8_t buf[65536];
ssize_t got = read(s.conns[ci].fd, buf, sizeof buf);
if (got <= 0) {
- conn_close(&s, ci); /* gone: the session keeps running regardless */
+ close_conn(&s, ci); /* gone: the session keeps running regardless */
continue;
}
msg_reader_feed(&s.conns[ci].reader, buf, (size_t)got);
@@ -647,49 +866,89 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
while (!closed && msg_reader_next(&s.conns[ci].reader, &m)) {
switch (m.type) {
case MSG_HELLO:
- case MSG_RESIZE:
- if (m.type == MSG_HELLO && !s.conns[ci].display) {
- /* now it is a client: the previous display is displaced */
- for (size_t j = 0; j < s.nconns; j++)
+ case MSG_RESIZE: {
+ conn_t *c = &s.conns[ci];
+ bool fresh = m.type == MSG_HELLO && !c->display;
+ bool first_viewer = fresh && display_count(&s) == 0;
+ if (!c->display && !fresh) break;
+
+ /* multi_attach false is the classic rule: the new client displaces
+ * every display before it (EXIT_REPLACED, which old clients already
+ * understand). Dropping compacts the array, so the index -- and the
+ * pointer taken from it -- are re-derived afterwards. */
+ if (fresh && !app_cfg_multi_attach()) {
+ for (size_t j = 0; j < s.nconns;) {
if (j != ci && s.conns[j].display) {
conn_drop(&s, j, EXIT_REPLACED);
if (j < ci) ci--; /* the array compacted under us */
- break;
- }
- s.conns[ci].display = true;
- /* Someone is now looking, which is the one moment a greeting
- * makes sense: a headless or scripted session never gets here. */
- app_splash(s.app);
+ } else
+ j++;
+ }
+ c = &s.conns[ci];
+ first_viewer = display_count(&s) == 0;
}
- if (m.len >= 4 && s.conns[ci].display) {
- uint16_t c = (uint16_t)(m.data[0] << 8 | m.data[1]);
- uint16_t rr = (uint16_t)(m.data[2] << 8 | m.data[3]);
- if (c && rr) {
- screen_resize(&s.screen, c, rr);
- app_resize(s.app, c, rr);
+
+ uint16_t cols = c->cols ? c->cols : s.screen.cols;
+ uint16_t rows = c->rows ? c->rows : s.screen.rows;
+ if (m.len >= 4) {
+ uint16_t got_cols = (uint16_t)(m.data[0] << 8 | m.data[1]);
+ uint16_t got_rows = (uint16_t)(m.data[2] << 8 | m.data[3]);
+ if (got_cols && got_rows) {
+ cols = got_cols;
+ rows = got_rows;
}
- /* The cell size arrived with it, from a client new enough to
- * send one and a terminal willing to say. Zero means neither,
- * and app_set_cell_px leaves the default standing. */
- if (m.len >= 8) {
- uint16_t cw = (uint16_t)(m.data[4] << 8 | m.data[5]);
- uint16_t ch = (uint16_t)(m.data[6] << 8 | m.data[7]);
- app_set_cell_px(s.app, cw, ch);
+ }
+ uint16_t cell_w = c->cell_w, cell_h = c->cell_h;
+ if (m.len >= 8) {
+ cell_w = (uint16_t)(m.data[4] << 8 | m.data[5]);
+ cell_h = (uint16_t)(m.data[6] << 8 | m.data[7]);
+ }
+
+ if (fresh) {
+ c->display = true;
+ c->cols = cols;
+ c->rows = rows;
+ c->cell_w = cell_w;
+ c->cell_h = cell_h;
+ c->in = input_new();
+ c->gfx = gfx_new();
+ screen_init(&c->view, cols, rows);
+ c->view_init = true;
+ make_active(&s, c, true);
+ if (first_viewer) {
+ app_splash(s.app);
+ app_compose(s.app, &s.screen);
}
+ } else {
+ bool local_resize = cols != c->cols || rows != c->rows;
+ c->cols = cols;
+ c->rows = rows;
+ c->cell_w = cell_w;
+ c->cell_h = cell_h;
+ if (local_resize) screen_resize(&c->view, cols, rows);
+ /* Unconditional: under "active" a non-active resize changes
+ * nothing (apply_canvas reads the active client), but under
+ * "smallest"/"largest" every display's size is a vote. */
+ apply_canvas(&s);
}
- s.screen.force_full = true; /* a fresh client knows nothing */
- app_graphics_reset(s.app); /* including any image we had sent */
- pending_paint = true;
- next_frame = now_ms();
+ update_viewport(c, &s.screen);
+ request_paint(&s, true);
break;
+ }
case MSG_INPUT:
- if (!s.conns[ci].display) break; /* only the client types */
- input_feed(s.in, m.data, m.len, srv_event, &s);
- esc_due = now_ms() + ESC_MS;
- pending_paint = true;
+ if (!s.conns[ci].display) break; /* only display clients type */
+ feed_client(&s, &s.conns[ci], m.data, m.len, false);
+ /* A selection copy belongs to the terminal whose mouse released it,
+ * even if another client types before this poll cycle is painted. */
+ push_clipboard_to(&s, &s.conns[ci]);
+ if (app_detach_requested(s.app)) {
+ app_clear_detach(s.app);
+ queue_detach(detach_fds, &ndetach, s.conns[ci].fd);
+ }
+ request_paint(&s, true);
break;
case MSG_DETACH:
- conn_drop(&s, ci, EXIT_DETACHED);
+ drop_conn(&s, ci, EXIT_DETACHED);
closed = true;
break;
case MSG_CMD: {
@@ -697,14 +956,17 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
* script written against one works against the other. */
bool q = false;
char *reply =
- cmd_exec(s.app, &s.screen, s.in, (const char *)m.data, &q);
+ cmd_exec(s.app, &s.screen, s.cmd_in, (const char *)m.data, &q);
const char *body = reply ? reply : "{\"error\":\"unknown command\"}";
conn_send(&s.conns[ci], MSG_REPLY, body, strlen(body));
free(reply);
if (q) g_stop = 1;
s.screen.force_full = true; /* it may have changed the layout */
- pending_paint = true;
- next_frame = now_ms();
+ if (app_detach_requested(s.app)) {
+ app_clear_detach(s.app);
+ queue_detach(detach_fds, &ndetach, s.active_fd);
+ }
+ request_paint(&s, true);
break;
}
default: break;
@@ -713,15 +975,27 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
if (!closed) ci++;
}
- if (input_pending(s.in) && now_ms() >= esc_due)
- input_timeout(s.in, srv_event, &s);
+ for (size_t i = 0; i < s.nconns; i++) {
+ conn_t *c = &s.conns[i];
+ if (c->display && input_pending(c->in) && now_ms() >= c->esc_due) {
+ feed_client(&s, c, NULL, 0, true);
+ push_clipboard_to(&s, c);
+ if (app_detach_requested(s.app)) {
+ app_clear_detach(s.app);
+ queue_detach(detach_fds, &ndetach, c->fd);
+ }
+ request_paint(&s, true);
+ }
+ }
+ for (size_t i = 0; i < ndetach; i++)
+ drop_fd(&s, detach_fds[i], EXIT_DETACHED);
for (size_t i = 0; i < npanes; i++) {
if (pfds[pane_slot + i].revents & (POLLIN | POLLHUP)) {
app_pump_fd(s.app, pfds[pane_slot + i].fd);
- if (!pending_paint) {
- pending_paint = true;
- next_frame = now_ms() + FRAME_MS;
+ if (!s.pending_paint) {
+ s.pending_paint = true;
+ s.next_frame = now_ms() + FRAME_MS;
}
}
}
@@ -747,12 +1021,24 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
* include becomes ten minutes of wondering. */
const char *why = app_config_complaint();
app_toast(s.app, why && *why ? why : "config reloaded");
+ /* The knobs this loop lives by may have moved. multi_attach turned
+ * off evicts every display but the active one -- the reloaded config
+ * says one client, so one client it is -- and a changed size_follows
+ * re-sizes the canvas to the policy now in force. */
+ if (!app_cfg_multi_attach()) {
+ for (size_t i = 0; i < s.nconns;) {
+ if (s.conns[i].display && s.conns[i].fd != s.active_fd)
+ drop_conn(&s, i, EXIT_REPLACED);
+ else
+ i++;
+ }
+ }
+ apply_canvas(&s);
} else {
app_toast(s.app, err[0] ? err : "config reload failed");
}
s.screen.force_full = true;
- pending_paint = true;
- next_frame = now_ms();
+ request_paint(&s, true);
}
app_reap(s.app);
@@ -760,35 +1046,34 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
/* The event-less work: a selection drag held past a pane's edge scrolls
* on a clock (app_next_deadline_ms is what woke the poll for it), and a
* step that moved anything is a frame the client is owed. */
- if (app_tick(s.app) && !pending_paint) {
- pending_paint = true;
- next_frame = now_ms();
+ if (app_tick(s.app) && !s.pending_paint) {
+ s.pending_paint = true;
+ s.next_frame = now_ms();
}
+ /* A detach raised by a non-display control command belongs to the active
+ * display. Display input records its own fd above before another client can
+ * become active. */
if (app_detach_requested(s.app)) {
app_clear_detach(s.app);
- drop_display(&s, EXIT_DETACHED);
+ drop_fd(&s, s.active_fd, EXIT_DETACHED);
}
- if (pending_paint && now_ms() >= next_frame) {
+ if (s.pending_paint && now_ms() >= s.next_frame) {
app_compose(s.app, &s.screen);
- /* Either push can find the outbox full and drop its frame. The model
- * remembers what the client is owed (a full repaint, the graphics
- * deletions and transmissions), so the retry that this keeps pending
- * is what eventually says it -- the outbox drains on POLLOUT between
- * frames. */
- bool frame_ok = push_frame(&s);
- bool gfx_ok = push_graphics(&s);
+ /* Each client remembers its own full repaint and image transmissions.
+ * One full outbox keeps a retry pending without holding anybody else up. */
+ bool clients_ok = push_clients(&s);
push_clipboard(&s);
- pending_paint = !(frame_ok && gfx_ok);
- next_frame = now_ms() + (pending_paint ? FRAME_MS : 0);
+ s.pending_paint = !clients_ok;
+ s.next_frame = now_ms() + (s.pending_paint ? FRAME_MS : 0);
}
}
while (s.nconns) conn_drop(&s, 0, EXIT_SESSION_ENDED);
unlink(path);
close(lfd);
- input_free(s.in);
+ input_free(s.cmd_in);
screen_free(&s.screen);
app_free(s.app);
return 0;