diff options
| author | user <user@clank> | 2026-09-02 14:57:46 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-02 14:57:46 +0200 |
| commit | bac4b796f3f729d8d76b9c7c973126eac49872f2 (patch) | |
| tree | 0d040eb64e433122f982ea45912eba37ab54822a /src | |
| parent | webdemo: version from contrib/version, published by ship (diff) | |
| parent | multi-attach: the config knobs (diff) | |
| download | slosh-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.c | 24 | ||||
| -rw-r--r-- | src/app_draw.c | 119 | ||||
| -rw-r--r-- | src/config.c | 27 | ||||
| -rw-r--r-- | src/main.c | 11 | ||||
| -rw-r--r-- | src/screen.c | 76 | ||||
| -rw-r--r-- | src/server.c | 493 |
6 files changed, 631 insertions, 119 deletions
@@ -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 = @@ -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; |
