3af899d9
refactor: the interaction core explains the rule in two lines
a73x 2026-08-30 19:31
Commit message
docscheck.blocks
| Old | New | ||
|---|---|---|---|
| @@ -9,7 +9,7 @@ engine.zig 17 | |||
| 9 | flags.zig 2 | 9 | flags.zig 2 |
| 10 | handoff.zig 26 | 10 | handoff.zig 26 |
| 11 | hosts.zig 5 | 11 | hosts.zig 5 |
| 12 | interact.zig 96 | 12 | interact.zig 24 |
| 13 | keymap.zig 1 | 13 | keymap.zig 1 |
| 14 | layout.zig 8 | 14 | layout.zig 8 |
| 15 | main.zig 57 | 15 | main.zig 57 |
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,23 +1,15 @@ | |||
| 1 | //! The session-interaction core: what happens between a user at a terminal | 1 | //! What happens between a user at a terminal and one attached session: the |
| 2 | //! and one attached session, with the dialling left out — the `Ctrl-\` | 2 | //! `Ctrl-\` chord layer, the mouse/wheel splitter and alternate scroll, |
| 3 | //! chord layer, the mouse/wheel splitter and alternate scroll, speculative | 3 | //! speculative echo, the side channels a session drives on the host terminal, |
| 4 | //! echo, the side channels a session drives on the host terminal (title, | 4 | //! and the terminal ownership those depend on. |
| 5 | //! clipboard, bell, modes), and the terminal ownership those depend on. | ||
| 6 | //! | 5 | //! |
| 7 | //! What does NOT live here is how a transport is BUILT or what a chord | 6 | //! Not here: how a transport is BUILT (client.zig) or what a chord MEANS — |
| 8 | //! MEANS. Targets, dialling, reconnect and the handoff are client.zig's; | 7 | //! `PrefixFilter` says which action was typed and each driver decides what it |
| 9 | //! `PrefixFilter` answers which action was typed and each driver decides | 8 | //! does. The transport is `anytype`, naming only `writeFrame`, so this loop |
| 10 | //! what that action does to its own world. | 9 | //! is transport-blind and testable with no dial behind it. |
| 11 | //! | 10 | //! |
| 12 | //! One driver: a wall tile's pump (`wallview.pumpTile`); every tile holds | 11 | //! Keeper of two CLAUDE.md invariants: prediction never enters the replica, |
| 13 | //! its own transport and replica, and nothing here dials. | 12 | //! and `replica.zig` is the one applier. |
| 14 | //! | ||
| 15 | //! The transport is `anytype` by choice, not by layering: naming only | ||
| 16 | //! `writeFrame(proto.MsgType, []const u8) !void` keeps this loop | ||
| 17 | //! transport-blind and testable with no dial behind it. | ||
| 18 | //! | ||
| 19 | //! Keeper of two CLAUDE.md invariants: prediction never enters the | ||
| 20 | //! replica, and `replica.zig` is the one applier. | ||
| 21 | 13 | ||
| 22 | const std = @import("std"); | 14 | const std = @import("std"); |
| 23 | const Engine = @import("term").engine.Engine; | 15 | const Engine = @import("term").engine.Engine; |
| @@ -34,19 +26,13 @@ const select = @import("select.zig"); | |||
| 34 | // watches for it with no session and no terminal in the picture. | 26 | // watches for it with no session and no terminal in the picture. |
| 35 | const detach_key = @import("client").keymap.detach_key; | 27 | const detach_key = @import("client").keymap.detach_key; |
| 36 | 28 | ||
| 37 | /// What `select` answers, in the shape `paint` asks for. | 29 | /// What `select` answers, in the shape `paint` asks for. The two are siblings |
| 30 | /// that may not import one another, so somebody above both joins them. | ||
| 38 | /// | 31 | /// |
| 39 | /// The two are siblings at layer 1 and may not import one another, so | 32 | /// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter |
| 40 | /// somebody above both has to join them; this is the smaller half of that | 33 | /// speaks grid rows, so the history count of the frame being painted turns one |
| 41 | /// join, and the wall reuses it rather than keeping a second copy | 34 | /// into the other — read from the replica the paint is walking, or the |
| 42 | /// (`wallview`'s Core sink). | 35 | /// highlight lands on another frame's lines. |
| 43 | /// | ||
| 44 | /// It converts as well as adapts. `select` speaks ABSOLUTE rows — | ||
| 45 | /// counted from the oldest row the daemon retains — and a painter speaks | ||
| 46 | /// grid rows, so the history count of the frame being painted is what | ||
| 47 | /// turns one into the other. Reading it from the same replica the paint | ||
| 48 | /// is walking is the point: a highlight resolved against one frame's | ||
| 49 | /// history and painted onto another's is a highlight on the wrong lines. | ||
| 50 | pub const Highlight = struct { | 36 | pub const Highlight = struct { |
| 51 | drag: *const select.Drag, | 37 | drag: *const select.Drag, |
| 52 | /// Which tile the drag belongs to. Zero for a focused `Core`, which is | 38 | /// Which tile the drag belongs to. Zero for a focused `Core`, which is |
| @@ -68,24 +54,15 @@ pub const Highlight = struct { | |||
| 68 | } | 54 | } |
| 69 | }; | 55 | }; |
| 70 | 56 | ||
| 71 | /// The attached client's keybinding layer: Ctrl-\ selects a command rather | 57 | /// The attached client's keybinding layer: `Ctrl-\` selects a command rather |
| 72 | /// than acting on its own. `d` or a second Ctrl-\ detach, `c` creates a new | 58 | /// than acting on its own. `d` or a second `Ctrl-\` detach, `c` creates a |
| 73 | /// session, `n` and `p` step to the next and previous one, `l` skips to the | 59 | /// session, `n`/`p` step, `l` skips to the last visited, `w` shows the wall; |
| 74 | /// last one visited, `w` shows the full wall; any other | 60 | /// any other key is dropped with the prefix. |
| 75 | /// key is dropped along with the prefix. Dropping is not a loss — a literal | ||
| 76 | /// 0x1c never reached the pty before this layer existed either. | ||
| 77 | /// | ||
| 78 | /// Only what is typed at an established session passes through here: the | ||
| 79 | /// keystrokes `attach` carried across the opening handshake go straight out | ||
| 80 | /// as input (see `carry`), because there was no session to command yet when | ||
| 81 | /// they were typed. | ||
| 82 | /// | 61 | /// |
| 83 | /// Public because the CLI wall's focused tile needs the same layer over the | 62 | /// Only what is typed at an ESTABLISHED session passes through: keystrokes |
| 84 | /// same keys (wallview.zig): a focused tile is a session on this terminal, | 63 | /// `attach` carried across the handshake go out as input, since there was no |
| 85 | /// and a second copy of this table would be a twin that drifts. What each | 64 | /// session to command yet. Public so the wall's focused tile shares the one |
| 86 | /// action MEANS is the caller's — in the wall `.detach` detaches and ends | 65 | /// table — what each action MEANS is the caller's, which byte spells it is not. |
| 87 | /// the run while `.next_session` moves the focus to the next session | ||
| 88 | /// (`wallview`'s run loop) — but which byte spells it is one table. | ||
| 89 | pub const PrefixFilter = struct { | 66 | pub const PrefixFilter = struct { |
| 90 | pub const Dir = enum { left, down, up, right }; | 67 | pub const Dir = enum { left, down, up, right }; |
| 91 | 68 | ||
| @@ -170,11 +147,9 @@ pub const PrefixFilter = struct { | |||
| 170 | /// shell. The driver paints; this owns which key means what. | 147 | /// shell. The driver paints; this owns which key means what. |
| 171 | picking: bool = false, | 148 | picking: bool = false, |
| 172 | 149 | ||
| 173 | /// ssh is waiting on an answer. Over EVERYTHING, `picking` and its | 150 | /// ssh is waiting on an answer. Over EVERYTHING, `picking` included: a |
| 174 | /// editor included: a prompt is not a mode the user chose, it arrived, | 151 | /// prompt is not a mode the user chose, it arrived. Every byte is the |
| 175 | /// and it arrives while whatever was on screen is still on screen. Every | 152 | /// popup's — a `j` typed at a password box must not reach a shell. |
| 176 | /// byte is the popup's, which is `picking`'s rule for `picking`'s | ||
| 177 | /// reason — a `j` typed at a password box must not reach a shell. | ||
| 178 | asking: bool = false, | 153 | asking: bool = false, |
| 179 | /// What ssh said it was asking for. The filter carries it to the | 154 | /// What ssh said it was asking for. The filter carries it to the |
| 180 | /// painter and decides nothing: a secret is starred, a confirmation is | 155 | /// painter and decides nothing: a secret is starred, a confirmation is |
| @@ -227,27 +202,16 @@ pub const PrefixFilter = struct { | |||
| 227 | }; | 202 | }; |
| 228 | } | 203 | } |
| 229 | 204 | ||
| 230 | /// Filters one raw stdin chunk in place — the layer only ever removes | 205 | /// Filters one raw stdin chunk in place — the layer only removes bytes, so |
| 231 | /// bytes, so the survivors compact leftwards over the same buffer. | 206 | /// survivors compact leftwards over the same buffer. An ACTION ends the |
| 232 | /// An action ends the chunk: whatever was typed behind it is dropped. | 207 | /// chunk and whatever was typed behind it is dropped: those bytes were |
| 233 | /// For `.detach` that is trivially right (the loop returns), and for a | 208 | /// typed at the OLD session, and forwarding them puts them in the wrong |
| 234 | /// switch it is the only honest answer — those bytes were typed at the | 209 | /// shell while returning them races a detach already on its way. |
| 235 | /// OLD session, so forwarding them to the new one would put them in the | ||
| 236 | /// wrong shell, and sending them back to the old one races the detach | ||
| 237 | /// that is already on its way. | ||
| 238 | /// | ||
| 239 | /// `.wall` re-cuts the wall and unzooms. It ends the chunk like every | ||
| 240 | /// chord above, so bytes typed behind it are dropped for the same | ||
| 241 | /// reason. `.add_tile` is the prompt's Enter: the line typed after | ||
| 242 | /// the picker's `a`, which the same rule ends the chunk on. | ||
| 243 | pub fn feed(self: *PrefixFilter, buf: []u8) Out { | 210 | pub fn feed(self: *PrefixFilter, buf: []u8) Out { |
| 244 | var kept: usize = 0; | 211 | var kept: usize = 0; |
| 245 | // The picker and its editor are MODES, so `s` and `a` do not end | 212 | // The picker and its editor are MODES, so `s` and `a` do not end the |
| 246 | // the read the way a one-shot chord does: the bytes behind them | 213 | // read: the bytes behind them were typed AT the mode that keystroke |
| 247 | // were typed AT the mode the same keystroke opened, and | 214 | // opened. Remembered, and reported only if nothing later said more. |
| 248 | // `\x1c s a --sock ...` in one read has to reach the line editor. | ||
| 249 | // The opens are remembered instead, and reported only if nothing | ||
| 250 | // later in the chunk had more to say. | ||
| 251 | var opened = false; | 215 | var opened = false; |
| 252 | var editing = false; | 216 | var editing = false; |
| 253 | for (buf, 0..) |b, i| { | 217 | for (buf, 0..) |b, i| { |
| @@ -260,22 +224,16 @@ pub const PrefixFilter = struct { | |||
| 260 | self.asking = false; | 224 | self.asking = false; |
| 261 | return .{ .forward = buf[0..kept], .action = .{ .ask_answer = self.ask_line[0..self.ask_len] } }; | 225 | return .{ .forward = buf[0..kept], .action = .{ .ask_answer = self.ask_line[0..self.ask_len] } }; |
| 262 | }, | 226 | }, |
| 263 | // The Esc that heads an arrow key takes its tail with | 227 | // The Esc that heads an arrow key takes its tail with it: |
| 264 | // it, the way the spelling editor's does: the chunk | 228 | // the chunk ends here, so `[A` never reaches a shell. |
| 265 | // ends here, so `[A` never reaches a shell. A prompt is | ||
| 266 | // not a popup you browse, so there is no key it has to | ||
| 267 | // stay open for. | ||
| 268 | 0x1b, 0x03 => { | 229 | 0x1b, 0x03 => { |
| 269 | self.asking = false; | 230 | self.asking = false; |
| 270 | return .{ .forward = buf[0..kept], .action = .ask_decline }; | 231 | return .{ .forward = buf[0..kept], .action = .ask_decline }; |
| 271 | }, | 232 | }, |
| 272 | 0x7f, 0x08 => self.ask_len -|= 1, | 233 | 0x7f, 0x08 => self.ask_len -|= 1, |
| 273 | // High bytes too, unlike the spelling editor's line: a | 234 | // High bytes too, unlike the spelling editor: a password is |
| 274 | // password is bytes and ssh takes any of them, so a | 235 | // bytes and ssh takes any of them. The star count is per |
| 275 | // UTF-8 one must be typeable. They are never an Esc | 236 | // byte — feedback, not an inventory. |
| 276 | // tail. The star count stays per byte, which is a | ||
| 277 | // character count only for ASCII — and a star count is | ||
| 278 | // feedback, not an inventory. | ||
| 279 | 0x20...0x7e, 0x80...0xff => if (self.ask_len < askpass.answer_max) { | 237 | 0x20...0x7e, 0x80...0xff => if (self.ask_len < askpass.answer_max) { |
| 280 | self.ask_line[self.ask_len] = b; | 238 | self.ask_line[self.ask_len] = b; |
| 281 | self.ask_len += 1; | 239 | self.ask_len += 1; |
| @@ -286,11 +244,9 @@ pub const PrefixFilter = struct { | |||
| 286 | } | 244 | } |
| 287 | if (self.prompting) { | 245 | if (self.prompting) { |
| 288 | switch (b) { | 246 | switch (b) { |
| 289 | // Submit and cancel both end the read: an Esc that is | 247 | // Submit and cancel both end the read: an Esc heading an |
| 290 | // the head of an arrow key or a mouse report must take | 248 | // arrow key must take its tail rather than hand `[A` to the |
| 291 | // its tail with it, not hand `[A` to the shell. Ctrl-C | 249 | // shell, and Ctrl-C is the reflex cancel. |
| 292 | // is the reflex cancel and ends it too, so a cancelled | ||
| 293 | // prompt is one behaviour whichever key reached for it. | ||
| 294 | '\r', '\n' => { | 250 | '\r', '\n' => { |
| 295 | self.prompting = false; | 251 | self.prompting = false; |
| 296 | if (self.line_len == 0) return .{ .forward = buf[0..kept], .action = .none }; | 252 | if (self.line_len == 0) return .{ .forward = buf[0..kept], .action = .none }; |
| @@ -312,21 +268,16 @@ pub const PrefixFilter = struct { | |||
| 312 | if (self.picking) { | 268 | if (self.picking) { |
| 313 | switch (b) { | 269 | switch (b) { |
| 314 | 0x1b => { | 270 | 0x1b => { |
| 315 | // A terminal writes an arrow key as three bytes of | 271 | // A terminal writes an arrow key as three bytes of one |
| 316 | // one read, so the tail decides which key this Esc | 272 | // read, so the tail decides which key this Esc was. |
| 317 | // was. Nothing is held across reads: a bare Escape | 273 | // Nothing is held across reads: a bare Escape closes now. |
| 318 | // has to close the popup on the press, not on | ||
| 319 | // whatever the user types next. | ||
| 320 | const tail = buf[i + 1 ..]; | 274 | const tail = buf[i + 1 ..]; |
| 321 | if (arrowMove(tail)) |d| | 275 | if (arrowMove(tail)) |d| |
| 322 | return .{ .forward = buf[0..kept], .action = .{ .pick_move = d } }; | 276 | return .{ .forward = buf[0..kept], .action = .{ .pick_move = d } }; |
| 323 | // A CSI or SS3 head the popup has no key for is not | 277 | // A CSI or SS3 head the popup has no key for is not an |
| 324 | // an Escape: the focused session's mouse modes stay | 278 | // Escape: mouse modes stay armed under the box, so a |
| 325 | // armed under the box, so a wheel notch or a click | 279 | // wheel notch would shut a popup being read. The tail |
| 326 | // arrives as `\x1b[<..M` and would otherwise shut a | 280 | // leaves too — its digits read as row selections. |
| 327 | // popup the user is only reading. The tail leaves | ||
| 328 | // with it — a mouse report's digits, left in the | ||
| 329 | // chunk, read as row selections. | ||
| 330 | if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O')) | 281 | if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O')) |
| 331 | return .{ .forward = buf[0..kept], .action = .none }; | 282 | return .{ .forward = buf[0..kept], .action = .none }; |
| 332 | self.picking = false; | 283 | self.picking = false; |
| @@ -432,41 +383,24 @@ const wheel_rows: u32 = 3; | |||
| 432 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones | 383 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones |
| 433 | /// into scrollback movement. | 384 | /// into scrollback movement. |
| 434 | /// | 385 | /// |
| 435 | /// TWO ROLES, and they are not alternatives. Every tile's `Core` owns a | 386 | /// TWO ROLES, not alternatives: every tile's `Core` owns one for a focused |
| 436 | /// filter for the bytes a focused session's terminal delivers; it runs only | 387 | /// session's bytes, bypassed entirely while an application in that session |
| 437 | /// while no application in that session has asked for the mouse, because | 388 | /// has asked for the mouse; and the wall owns one to hit-test a press for |
| 438 | /// when one has, the bytes are its own and the filter is bypassed entirely | 389 | /// focus, passing the report bytes on so the drag's Core still sees them. |
| 439 | /// (and reset, so a report split across that transition cannot be | 390 | /// Only the SGR form is recognised — the only form the client asks for. |
| 440 | /// half-eaten). The CLI wall owns one more, in its keyboard loop, to | ||
| 441 | /// hit-test a press for focus — the report bytes themselves pass through to | ||
| 442 | /// the focused tile, so the Core that owns the drag sees them. | ||
| 443 | /// | ||
| 444 | /// Only the SGR form (`ESC [ < b ; x ; y M|m`) is recognised, because it is | ||
| 445 | /// the only form the client ever asks its terminal for (`client_mouse_setup`). | ||
| 446 | /// | 391 | /// |
| 447 | /// What it deliberately does NOT do is hold a bare `ESC` or `ESC [` across | 392 | /// It never holds a bare `ESC` across a read boundary: that is the complete |
| 448 | /// a read boundary waiting to see whether a mouse report follows. That | 393 | /// parse, and it would leave a bare Escape typed in vim sitting here until |
| 449 | /// would be the complete parse, and it would cost the one thing a | 394 | /// the next keystroke. The hold starts at `ESC [ <`, three bytes no keyboard |
| 450 | /// multiplexer must never delay: a bare Escape typed in vim would sit here | 395 | /// produces, so only a split inside those three leaks through as input. |
| 451 | /// until the next keystroke. So the hold starts at `ESC [ <` — three bytes | ||
| 452 | /// no keyboard produces — and a report split inside those three bytes | ||
| 453 | /// leaks through as input. A terminal writes a report with one write, and | ||
| 454 | /// the pty delivers up to 16 KiB per read, so that split is a theoretical | ||
| 455 | /// one; a delayed Escape would be an every-session one. | ||
| 456 | pub const MouseFilter = struct { | 396 | pub const MouseFilter = struct { |
| 457 | /// Longest report worth holding: `ESC [ <` plus three parameters. A | 397 | /// Longest report worth holding: `ESC [ <` plus three parameters. Public |
| 458 | /// candidate that outgrows it was never a mouse report. | 398 | /// because a caller must size `feed`'s buffer at `in.len + max_held`. |
| 459 | /// | ||
| 460 | /// Public because a caller has to SIZE the buffer it passes `feed`, and | ||
| 461 | /// the size is `in.len + max_held`. | ||
| 462 | pub const max_held = 24; | 399 | pub const max_held = 24; |
| 463 | 400 | ||
| 464 | /// One report the filter understood, in this client's coordinates. | 401 | /// One report the filter understood, in this client's coordinates. |
| 465 | /// | 402 | /// `button` is the SGR word verbatim: what a click MEANS differs between |
| 466 | /// `button` is the SGR button word verbatim, modifier bits and all — | 403 | /// the wall's keyboard and a focused tile, so decoding is the driver's. |
| 467 | /// decoding it is the DRIVER's business, because what a click means | ||
| 468 | /// differs between the wall's keyboard and a focused tile and this filter must not | ||
| 469 | /// have to know which it is feeding. | ||
| 470 | pub const Event = struct { | 404 | pub const Event = struct { |
| 471 | pub const Kind = enum { press, motion, release }; | 405 | pub const Kind = enum { press, motion, release }; |
| 472 | 406 | ||
| @@ -478,34 +412,19 @@ pub const MouseFilter = struct { | |||
| 478 | /// at the edge beats one at every use. | 412 | /// at the edge beats one at every use. |
| 479 | col: u16, | 413 | col: u16, |
| 480 | row: u16, | 414 | row: u16, |
| 481 | /// How many bytes of `Out.forward` had been emitted when this | 415 | /// How many bytes of `Out.forward` had been emitted when this report |
| 482 | /// report completed — its position among the keys, not its index | 416 | /// completed — its position among the KEYS, not its index among the |
| 483 | /// among the events. | 417 | /// events. `feed` returns two flat lists, so without this the |
| 484 | /// | 418 | /// interleaving is lost: one read holding `\r` and a click focuses the |
| 485 | /// A read carries keys and reports interleaved, and `feed` returns | 419 | /// tile the click selected rather than the one the user chose. |
| 486 | /// them as two flat lists; without this the interleaving is lost. | ||
| 487 | /// The case that costs is one read holding `\r` and a click: at the | ||
| 488 | /// wall, a press moves the focus, so draining all the events first focuses the tile | ||
| 489 | /// the click had just selected rather than the one the user chose. | ||
| 490 | /// Two orderings, one flattening, opposite right answers. | ||
| 491 | /// | ||
| 492 | /// Reports at the same position share an `at`; drivers drain in | ||
| 493 | /// order and the ties are already in the order they arrived. | ||
| 494 | at: usize, | 420 | at: usize, |
| 495 | }; | 421 | }; |
| 496 | 422 | ||
| 497 | /// The most reports one `feed` can produce: the shortest complete SGR | 423 | /// The most reports one `feed` can produce: the shortest complete SGR |
| 498 | /// report is NINE bytes (`ESC [ < 0 ; 1 ; 1 M`), and a held candidate | 424 | /// report is NINE bytes, and a candidate held from the previous read can |
| 499 | /// carried in from the previous read can complete at most one more. | 425 | /// complete at most one more. Sized rather than coalesced so the filter is |
| 500 | /// Sized rather than coalesced so the filter is total — a drag that | 426 | /// total — a drag that outran a cap would lose its release. The nine is |
| 501 | /// outran a cap would lose the release and strand the drag state | 427 | /// counted: ten leaves the array 181 short of a full chunk. |
| 502 | /// machine mid-drag. | ||
| 503 | /// | ||
| 504 | /// The nine is counted, not eyeballed. It read ten here first, which | ||
| 505 | /// left the array 181 short of a full chunk and `feed` writing off the | ||
| 506 | /// end of it — reachable by nothing on this branch, because both | ||
| 507 | /// callers happen to read into `mailbox_max`, and a panic the moment | ||
| 508 | /// anyone fed it the stdin chunk this constant is named for. | ||
| 509 | const max_events = stdin_chunk / 9 + 1; | 428 | const max_events = stdin_chunk / 9 + 1; |
| 510 | 429 | ||
| 511 | pub const Out = struct { | 430 | pub const Out = struct { |
| @@ -526,17 +445,10 @@ pub const MouseFilter = struct { | |||
| 526 | self.len = 0; | 445 | self.len = 0; |
| 527 | } | 446 | } |
| 528 | 447 | ||
| 529 | /// Filter one raw stdin chunk into `out`, which must have room for | 448 | /// Filter one raw stdin chunk into `out`, which must hold |
| 530 | /// `in.len + max_held` — a candidate held from the previous read is | 449 | /// `in.len + max_held`: a candidate held from the previous read comes back |
| 531 | /// handed back ahead of this chunk's bytes when it turns out not to | 450 | /// ahead of this chunk when it turns out not to have been a report. Both |
| 532 | /// have been a report after all. | 451 | /// size preconditions are ASSERTED, not merely stated here. |
| 533 | /// | ||
| 534 | /// Both size preconditions are asserted rather than left in this | ||
| 535 | /// paragraph. They were prose only while this struct was private with | ||
| 536 | /// one caller; it is public with two now, and `max_events` is derived | ||
| 537 | /// from `stdin_chunk` while both callers happen to read into a buffer | ||
| 538 | /// a quarter that size — exactly the kind of accident that holds until | ||
| 539 | /// someone reads the doc and believes it. | ||
| 540 | pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { | 452 | pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { |
| 541 | std.debug.assert(in.len <= stdin_chunk); | 453 | std.debug.assert(in.len <= stdin_chunk); |
| 542 | std.debug.assert(out.len >= in.len + max_held); | 454 | std.debug.assert(out.len >= in.len + max_held); |
| @@ -551,11 +463,9 @@ pub const MouseFilter = struct { | |||
| 551 | wheel += wheelNotches(seq); | 463 | wheel += wheelNotches(seq); |
| 552 | if (decodeEvent(seq)) |ev| { | 464 | if (decodeEvent(seq)) |ev| { |
| 553 | self.events[evs] = ev; | 465 | self.events[evs] = ev; |
| 554 | // `kept` is already past the rewind that started | 466 | // `kept` is already past the rewind that started this |
| 555 | // this candidate — the three bytes of `ESC [ <` | 467 | // candidate, so this is a position in the FINAL forward |
| 556 | // came back out of `forward` when it opened — so | 468 | // and not a provisional one that later shrinks. |
| 557 | // this is the position in the FINAL forward, not a | ||
| 558 | // provisional one that later shrinks. | ||
| 559 | self.events[evs].at = kept; | 469 | self.events[evs].at = kept; |
| 560 | evs += 1; | 470 | evs += 1; |
| 561 | } | 471 | } |
| @@ -618,20 +528,16 @@ pub const MouseFilter = struct { | |||
| 618 | } | 528 | } |
| 619 | 529 | ||
| 620 | /// The wheel movement one complete SGR report means, or 0 for anything | 530 | /// The wheel movement one complete SGR report means, or 0 for anything |
| 621 | /// else — a click, a drag, a release, a horizontal wheel, a button this | 531 | /// else. Discarding those is the point: with no application asking for the |
| 622 | /// terminal invented. Discarding those is the point: with no | 532 | /// mouse, forwarding one types `[<0;40;12M` into the user's shell. |
| 623 | /// application asking for the mouse there is nobody to send them to, | ||
| 624 | /// and forwarding them would type `[<0;40;12M` into the user's shell. | ||
| 625 | fn wheelNotches(seq: []const u8) i32 { | 533 | fn wheelNotches(seq: []const u8) i32 { |
| 626 | // Wheel events are presses; a release cannot be one. | 534 | // Wheel events are presses; a release cannot be one. |
| 627 | if (seq[seq.len - 1] != 'M') return 0; | 535 | if (seq[seq.len - 1] != 'M') return 0; |
| 628 | var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';'); | 536 | var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';'); |
| 629 | const button = std.fmt.parseInt(u16, it.first(), 10) catch return 0; | 537 | const button = std.fmt.parseInt(u16, it.first(), 10) catch return 0; |
| 630 | // Bit 6 marks the wheel buttons, bit 5 marks motion (a drag with | 538 | // Bit 6 marks the wheel buttons, bit 5 motion (a drag with the wheel |
| 631 | // the wheel held is not a scroll). The low two bits pick which of | 539 | // held is not a scroll); the low two bits pick vertical from |
| 632 | // the four: 0/1 are vertical, 2/3 horizontal and unhandled. The | 540 | // horizontal. Modifiers are ignored, so Ctrl+wheel scrolls. |
| 633 | // modifier bits (shift/meta/ctrl, 4/8/16) are ignored on purpose — | ||
| 634 | // Ctrl+wheel is a font-size change nobody here implements, so it scrolls. | ||
| 635 | if (button & 0x40 == 0 or button & 0x20 != 0) return 0; | 541 | if (button & 0x40 == 0 or button & 0x20 != 0) return 0; |
| 636 | return switch (button & 0x03) { | 542 | return switch (button & 0x03) { |
| 637 | 0 => 1, | 543 | 0 => 1, |
| @@ -666,95 +572,56 @@ pub fn winchRaised() bool { | |||
| 666 | } | 572 | } |
| 667 | 573 | ||
| 668 | /// This terminal's size, or null when there is no terminal to measure. | 574 | /// This terminal's size, or null when there is no terminal to measure. |
| 669 | /// | 575 | /// Public because the wall cuts its stripes before it has a Core, and a |
| 670 | /// Public because a driver that lays a screen out BEFORE it has a Core to | 576 | /// second copy would drift on exactly the 0x0 case below. |
| 671 | /// measure it needs the same answer: the CLI wall cuts its stripes from the | ||
| 672 | /// terminal at startup, and a second copy of this would be a twin that | ||
| 673 | /// drifts on exactly the 0x0 case below. | ||
| 674 | pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { | 577 | pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { |
| 675 | if (!std.posix.isatty(fd)) return null; | 578 | if (!std.posix.isatty(fd)) return null; |
| 676 | var ws: std.posix.winsize = undefined; | 579 | var ws: std.posix.winsize = undefined; |
| 677 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | 580 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; |
| 678 | // A pty can report 0x0 (e.g. `script` with piped stdin); a zero-sized | 581 | // A pty can report 0x0 and a zero-sized grid is invalid for the engine, |
| 679 | // grid is invalid for the engine. Treat it as "unknown". | 582 | // so that is "unknown". The floor is the daemon's own, read rather than |
| 680 | // | 583 | // respelled: a size it refuses to move is as unusable as no size at all. |
| 681 | // The floor is the daemon's own (`proto.min_session_cols`), read rather | ||
| 682 | // than respelled: a terminal measured below it and quoted is a size the | ||
| 683 | // daemon refuses to move, so "too small to use" and "unknown" get the | ||
| 684 | // same answer here. A second spelling is a second chance to freeze a | ||
| 685 | // thin stripe when the floor moves. | ||
| 686 | if (ws.col < proto.min_session_cols or ws.row < proto.min_session_rows) return null; | 584 | if (ws.col < proto.min_session_cols or ws.row < proto.min_session_rows) return null; |
| 687 | return .{ .cols = ws.col, .rows = ws.row }; | 585 | return .{ .cols = ws.col, .rows = ws.row }; |
| 688 | } | 586 | } |
| 689 | 587 | ||
| 690 | // ---- side channels ----------------------------------------------------- | 588 | // ---- side channels ----------------------------------------------------- |
| 691 | // | ||
| 692 | // Everything below turns a typed semantic value into bytes for the host | 589 | // Everything below turns a typed semantic value into bytes for the host |
| 693 | // terminal. Untrusted wire validation belongs to client_core; these adapters | 590 | // terminal. Untrusted wire validation belongs to client_core; these adapters |
| 694 | // only perform the native platform operation selected by that shared core. | 591 | // only perform the operation that shared core selected. |
| 695 | 592 | ||
| 696 | /// Everything a driver does TO the host terminal to own a SCREEN, in the | 593 | /// Everything a driver does TO the host terminal to own a SCREEN, in order: |
| 697 | /// order it does it: push the title, enter the alternate screen, hide the | 594 | /// push the title, enter the alternate screen, hide the cursor, disable |
| 698 | /// cursor, disable autowrap. Named rather than inline because it is one | 595 | /// autowrap. One half of a pair — `terminal_teardown` undoes each, and one |
| 699 | /// half of a pair — `terminal_teardown` undoes each of these, and the pair | 596 | /// test pins them together so neither drifts alone. Written once for a |
| 700 | /// is pinned together in one test so neither half can drift alone. | 597 | /// driver's LIFETIME, not per session: the wall holds the screen while focus |
| 701 | /// | 598 | /// moves and a tile writes only `session_claim`. Autowrap goes off so an |
| 702 | /// Written once for a driver's whole LIFETIME rather than per session, | 599 | /// oversized row clips at the right edge instead of shifting the paint down. |
| 703 | /// because the driver is the wall (`wall_setup`): it holds the screen while | ||
| 704 | /// focus moves between its tiles, and a tile writes only | ||
| 705 | /// `session_claim`. | ||
| 706 | /// | ||
| 707 | /// Autowrap (`?7l`) goes off with the alternate screen and for its sake: an | ||
| 708 | /// oversized grid row must clip at the right edge rather than wrap and | ||
| 709 | /// shift the whole paint down a line. | ||
| 710 | const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l"; | 600 | const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l"; |
| 711 | 601 | ||
| 712 | /// The half that is about holding a SESSION on somebody's terminal: the | 602 | /// The half about holding a SESSION on somebody's terminal: the mouse modes |
| 713 | /// mouse modes its wheel is read out of, and nothing else. A wall tile's | 603 | /// its wheel is read out of, and nothing else. A tile's focus claim writes |
| 714 | /// focus claim writes this first (`Core.claimTerminal`), then the session's own | 604 | /// this, then the session's own modes on top. One shared constant, so a mode |
| 715 | /// modes on top — the wall owns the screen for its whole life and a tile | 605 | /// added here is added to every claim and one dropped from `session_release` |
| 716 | /// may not touch it. | 606 | /// is dropped from every path that undoes one. |
| 717 | /// | 607 | /// |
| 718 | /// Spelled as a shared constant rather than repeated, so the pairing is | 608 | /// The enables are the CLIENT's own: with no mouse reporting on, a host |
| 719 | /// structural: this is the session half and `wall_setup` is the screen | 609 | /// terminal answers the wheel by synthesising arrow keys (DEC 1007), which |
| 720 | /// half, and `session_release` appears verbatim inside every teardown. | 610 | /// move the shell's history instead of the view. |
| 721 | /// A mouse mode added here is added to every claim there is, and one | ||
| 722 | /// dropped from the release is dropped from every path that undoes a claim. | ||
| 723 | /// | ||
| 724 | /// The mouse enables are the CLIENT's own, not a session's: with no mouse | ||
| 725 | /// reporting on, a host terminal answers the wheel by synthesising arrow | ||
| 726 | /// keys on the alternate screen (DEC 1007, "alternate scroll"), which land | ||
| 727 | /// in the session as input and move the shell's history instead of the | ||
| 728 | /// view. Asking for real wheel events is what makes the wheel scrollable at | ||
| 729 | /// all, and it turns 1007's synthesis off as a side effect. | ||
| 730 | const session_claim = client_mouse_setup; | 611 | const session_claim = client_mouse_setup; |
| 731 | 612 | ||
| 732 | /// The mouse modes the client asks its own terminal for when no application | 613 | /// The mouse modes the client asks its own terminal for when no application |
| 733 | /// in the session wants them: button presses (1000) and motion while a | 614 | /// in the session wants them: presses (1000) and motion with a button down |
| 734 | /// button is down (1002), both reported in SGR (1006). | 615 | /// (1002), in SGR (1006). |
| 735 | /// | ||
| 736 | /// 1002 is held for the whole run rather than armed for the length of a | ||
| 737 | /// drag, because there is no such length to arm it for. Motion reporting | ||
| 738 | /// has to be on BEFORE the press whose drag it reports, and at press time | ||
| 739 | /// nothing here knows whether a drag is coming — an escalation that can | ||
| 740 | /// only be decided after the fact is not an escalation. | ||
| 741 | /// | 616 | /// |
| 742 | /// What that costs is a report per motion event, read and, until something | 617 | /// 1002 is held for the whole run because there is no drag-length to arm it |
| 743 | /// wants one, dropped. That cost is why this set stopped at 1000, and it | 618 | /// for: motion reporting has to be on BEFORE the press whose drag it reports. |
| 744 | /// stopped being decisive when the wall itself became a mouse claimant | 619 | /// The cost is a report per motion event; nvim with `set mouse=a` pays the |
| 745 | /// (`client_mouse_setup`): the reports now have a consumer. The price is also | 620 | /// same. 1003 stays refused — motion with NO button down answers no question |
| 746 | /// one every editor already pays — nvim with `set mouse=a` sets exactly | 621 | /// this client asks. |
| 747 | /// `?1002h` and `?1006h` and holds them for the whole session (measured | ||
| 748 | /// 2026-08-21). | ||
| 749 | /// | 622 | /// |
| 750 | /// 1003 stays refused, and for the reason 1002 no longer is: it reports | 623 | /// The LIST is the fact, not the escape: `appendMouseModes` level-sets the |
| 751 | /// motion with NO button down, which is a report per cursor twitch over the | 624 | /// same modes, so one spelled here and not there is turned on then off. |
| 752 | /// window and answers no question this client asks. | ||
| 753 | /// | ||
| 754 | /// The list, not the escape, is the fact: `client_mouse_setup` writes it at | ||
| 755 | /// startup and `appendMouseModes` level-sets the same modes on every sample, | ||
| 756 | /// so a mode spelled in one place and not the other would be one the client | ||
| 757 | /// turns on and then immediately turns off. | ||
| 758 | const client_mouse_capture = [_]u16{ 1000, 1002, 1006 }; | 625 | const client_mouse_capture = [_]u16{ 1000, 1002, 1006 }; |
| 759 | 626 | ||
| 760 | const client_mouse_setup = blk: { | 627 | const client_mouse_setup = blk: { |
| @@ -771,106 +638,43 @@ fn inClientCapture(comptime dec: u16) bool { | |||
| 771 | return false; | 638 | return false; |
| 772 | } | 639 | } |
| 773 | 640 | ||
| 774 | /// Everything the client must undo on its way out, in one literal. mux | 641 | /// Everything the client must undo on its way out, in one literal: leaving |
| 775 | /// turns these on; leaving any of them set hands the user a terminal that | 642 | /// any of these set hands the user a terminal that behaves oddly long after |
| 776 | /// behaves oddly long after mux exited, with nothing on screen to explain | 643 | /// mux exited. `?2004l` leads because a SESSION asked for it. |
| 777 | /// it. `?2004l` leads because it is the one a session asked for rather | ||
| 778 | /// than one the client needed for itself. | ||
| 779 | /// | ||
| 780 | /// Note what the `?2004l` assumes: it restores 2004 to the terminal's | ||
| 781 | /// power-on default rather than to whatever the outer program had, because | ||
| 782 | /// mux never asked the host what it had. That is correct rather than merely | ||
| 783 | /// tolerable, and by observation rather than by hope — a zsh running under | ||
| 784 | /// a pty writes `?2004h`, `?2004l`, `?2004h`, `?2004l`: it arms bracketed | ||
| 785 | /// paste when zle starts reading and DISARMS it before running each | ||
| 786 | /// command. readline does the same. So for the whole time mux runs as a | ||
| 787 | /// child of the shell that launched it, host 2004 is already off, and off | ||
| 788 | /// is exactly what we put back. A host that armed 2004 and then ran a child | ||
| 789 | /// without disarming would be restored wrongly — no shell in use here does. | ||
| 790 | /// | 644 | /// |
| 791 | /// The title pop (`23;0t`) is the one entry here that restores the user's | 645 | /// That `?2004l` restores 2004 to the power-on default rather than to what |
| 792 | /// OWN value rather than a power-on default — the terminal kept it on its | 646 | /// the outer program had, because mux never asked. Correct by observation: |
| 793 | /// stack, because mux cannot read a title back to restore it by hand. | 647 | /// zsh and readline both DISARM bracketed paste before running each command, |
| 648 | /// so host 2004 is already off for the whole time mux runs. | ||
| 794 | /// | 649 | /// |
| 795 | /// It sits SECOND TO LAST, and that placement is load-bearing even though | 650 | /// The title pop (`23;0t`) is the one entry restoring the user's OWN value, |
| 796 | /// the title stack and the alternate screen have nothing to do with each | 651 | /// off the terminal's stack, because mux cannot read a title back. It sits |
| 797 | /// other. `?1049l` must remain the final bytes a tty client writes: the | 652 | /// SECOND TO LAST: `?1049l` must remain the final bytes a tty client writes, |
| 798 | /// e2e doctored control for the pty capture (test/e2e.sh, tp1) asserts the | 653 | /// which the e2e's pty capture asserts by dropping exactly that tail. |
| 799 | /// capture's last 8 bytes ARE that exit, then drops them so its appended | ||
| 800 | /// row lands INSIDE the alt screen — `render` replays only up to the last | ||
| 801 | /// alt-screen exit, so an append after one changes nothing. Measured, not | ||
| 802 | /// guessed: the plain append rendered byte-identical, i.e. a control that | ||
| 803 | /// could never fire. A teardown that stops ending in `?1049l` now fails | ||
| 804 | /// that tail assertion instead of silently going back to a no-op. | ||
| 805 | /// | ||
| 806 | /// It is here because the question was ANSWERED, not assumed: the operator | ||
| 807 | /// ran the push/set/pop probe in a bare Alacritty window on 2026-08-15 and | ||
| 808 | /// the title returned (commit 217183c, and the design note it edits). A | ||
| 809 | /// terminal without the stack ignores both halves, which costs a title bar | ||
| 810 | /// left showing what the session set — the tmux behaviour, and the | ||
| 811 | /// fallback this would otherwise have shipped as. | ||
| 812 | const terminal_teardown = session_release ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; | 654 | const terminal_teardown = session_release ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; |
| 813 | 655 | ||
| 814 | /// Undoes `session_claim`, and everything a SESSION can have asked this | 656 | /// Undoes `session_claim` and everything a SESSION asked this terminal for |
| 815 | /// terminal for while it held it: `?2004l` because the session armed | 657 | /// while it held it: bracketed paste, and the whole mouse table, since a |
| 816 | /// bracketed paste, the whole mouse table because the session's own modes | 658 | /// focused tile's application can have asked for modes the client never did. |
| 817 | /// were mirrored onto this terminal (`appendMouseModes`) and a focused | ||
| 818 | /// tile's application can have asked for modes the client never wanted. | ||
| 819 | /// | ||
| 820 | /// It leads `terminal_teardown` — the exit path — and it is inside | ||
| 821 | /// `wall_teardown`. Every route out of a terminal claim writes these | ||
| 822 | /// bytes; that is the pairing, and one test pins all four. | ||
| 823 | /// | 659 | /// |
| 824 | /// Public because the focus move does not write it from here. A wall's | 660 | /// Every route out of a terminal claim writes these bytes, and one test pins |
| 825 | /// focus change and a tile's pump are different threads, and the release | 661 | /// all four. Public because the focus move writes it from another thread: the |
| 826 | /// has to be ordered against the NEXT tile's claim rather than merely | 662 | /// release must be ORDERED against the next tile's claim, not merely happen. |
| 827 | /// happen — so the thread that moves the focus writes it, before the store | ||
| 828 | /// that lets | ||
| 829 | /// the next pump see the move (wallview's `setFocus`). Exported rather than | ||
| 830 | /// duplicated, for `wall_setup`/`wall_teardown`'s reason: a mouse mode | ||
| 831 | /// added to the claim must not need finding in four places. | ||
| 832 | pub const session_release = "\x1b[?2004l" ++ mouse_teardown; | 663 | pub const session_release = "\x1b[?2004l" ++ mouse_teardown; |
| 833 | 664 | ||
| 834 | /// What a driver that lends its screen to one session at a time writes on | 665 | /// What a driver that lends its screen to one session at a time writes on the |
| 835 | /// the way in and on the way out. The CLI wall is the driver: it holds the | 666 | /// way in and out. The wall is the driver: it holds the alternate screen for |
| 836 | /// alternate screen for its whole life while focus moves between its | 667 | /// its whole life, so the screen half is written once and never by a tile. |
| 837 | /// tiles, so the screen half is written once here and never by a | ||
| 838 | /// tile. | ||
| 839 | /// | ||
| 840 | /// The teardown is `terminal_teardown` itself, and that identity is the | ||
| 841 | /// design rather than a coincidence. The wall can be left while a tile is | ||
| 842 | /// still holds the terminal — `Ctrl-\ d` ends the run from any state — | ||
| 843 | /// so every mode a session set through that tile (bracketed paste, the | ||
| 844 | /// mouse modes it asked for) has to come off here as well as at the focus | ||
| 845 | /// handover. A wall exit and a client exit are the same | ||
| 846 | /// terminal, restored the same way; the only difference is that the wall | ||
| 847 | /// wrote the screen half once for N sessions. | ||
| 848 | /// | 668 | /// |
| 849 | /// The TITLE is the one thing that is NOT symmetric, deliberately. A | 669 | /// The teardown IS `terminal_teardown`, by design: `Ctrl-\ d` ends the run |
| 850 | /// focused tile sets it and a focus move leaves it standing: the wall pushed | 670 | /// from any state, so every mode a session set through a tile has to come off |
| 851 | /// one title for its whole life and pops it here, so a user who focuses | 671 | /// here as well as at the focus handover. |
| 852 | /// through four sessions sees four titles and gets their own back when the | ||
| 853 | /// wall exits. Restoring at each focus move would need a title to restore TO, | ||
| 854 | /// and mux cannot read one back — the tmux behaviour, and the right one. | ||
| 855 | /// | 672 | /// |
| 856 | /// That push (`22;0t`, "icon name and window title", matching the OSC 0 | 673 | /// The TITLE is deliberately not symmetric. A focus move leaves the session's |
| 857 | /// `appendTermTitle` writes) is exactly one deep, and the guarantee was | 674 | /// title standing; the wall pushed one for its whole life and pops it here. |
| 858 | /// checked rather than assumed — an unmatched POP does not restore a | 675 | /// Restoring per focus move would need a title to restore TO, and mux cannot |
| 859 | /// title, it pops whatever the terminal had underneath, which is somebody | 676 | /// read one back. That push is exactly one deep — an unmatched POP restores |
| 860 | /// else's. This write and the pop inside `terminal_teardown` are the only | 677 | /// somebody else's title — and this write and that pop are the only two. |
| 861 | /// two in the tree, and the wall writes each once: one path in, and every | ||
| 862 | /// path out is the same teardown. What escapes is a signal that kills the | ||
| 863 | /// process outright — and that loses `?1049l` and `?25h` with it, leaving | ||
| 864 | /// the user on an alternate screen with no cursor, so a title stack one | ||
| 865 | /// deeper is not the part they will notice. Same exposure as every other | ||
| 866 | /// line of the teardown, not a new one. | ||
| 867 | /// | ||
| 868 | /// The wall no longer claims the mouse itself: the focused tile's | ||
| 869 | /// `claimTerminal` arms `session_claim`, so a click reaches the session | ||
| 870 | /// that asked for it and a click in another tile's rect moves the focus | ||
| 871 | /// there. `session_release` (written by the keyboard on a focus move) | ||
| 872 | /// clears the whole wire table, and the newly focused tile's claim puts | ||
| 873 | /// the modes back — ordered by the `paint_mu` lock, not by a re-arm here. | ||
| 874 | pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J"; | 678 | pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J"; |
| 875 | pub const wall_teardown = terminal_teardown; | 679 | pub const wall_teardown = terminal_teardown; |
| 876 | 680 | ||
| @@ -969,11 +773,9 @@ fn appendTermTitle( | |||
| 969 | try out.append(alloc, 0x07); | 773 | try out.append(alloc, 0x07); |
| 970 | } | 774 | } |
| 971 | 775 | ||
| 972 | /// `owns_terminal` gates every channel: the claim arms the teardown, so | 776 | /// `owns_terminal` gates every channel: the claim arms the teardown, so a |
| 973 | /// a mode set under `.none` is one nothing undoes. | 777 | /// mode set under `.none` is one nothing undoes. `append` is declared, not |
| 974 | /// | 778 | /// `anytype`: allocation is a builder's only failure, so empty means refusal. |
| 975 | /// `append` is declared, not `anytype`: allocation is a builder's only | ||
| 976 | /// failure, so empty means refusal. | ||
| 977 | fn writeSideChannel( | 779 | fn writeSideChannel( |
| 978 | alloc: std.mem.Allocator, | 780 | alloc: std.mem.Allocator, |
| 979 | stdout_fd: std.posix.fd_t, | 781 | stdout_fd: std.posix.fd_t, |
| @@ -990,17 +792,12 @@ fn writeSideChannel( | |||
| 990 | } | 792 | } |
| 991 | 793 | ||
| 992 | // ---- prediction -------------------------------------------------------- | 794 | // ---- prediction -------------------------------------------------------- |
| 993 | // | 795 | // Nothing below writes to the replica (see the invariants at the top), which |
| 994 | // Nothing below writes to the replica — see the invariants at the top of the | 796 | // is why the replica stays comparable to `mux d dump` at every instant. |
| 995 | // file. It is why the replica stays comparable to `mux d dump` at every | ||
| 996 | // instant. | ||
| 997 | 797 | ||
| 998 | /// What the replica shows at one cell — the `prev_ch` a prediction is | 798 | /// What the replica shows at one cell — the `prev_ch` a prediction is judged |
| 999 | /// judged against later. | 799 | /// against later. Read at the PREDICTED cursor, not the replica's own: |
| 1000 | /// | 800 | /// mid-burst the wrong one turns "not answered yet" into "contradicted". |
| 1001 | /// Read at the PREDICTED cursor, not the replica's own: mid-burst those | ||
| 1002 | /// are different cells, and the wrong one turns "not answered yet" into | ||
| 1003 | /// "contradicted" and flushes the queue. | ||
| 1004 | fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 { | 801 | fn replicaCellChar(alloc: std.mem.Allocator, replica: *Engine, at: predict.CursorPos) u8 { |
| 1005 | const plain = replica.dumpPlain(alloc) catch return ' '; | 802 | const plain = replica.dumpPlain(alloc) catch return ' '; |
| 1006 | defer alloc.free(plain); | 803 | defer alloc.free(plain); |
| @@ -1082,12 +879,9 @@ fn offerKeystroke( | |||
| 1082 | out_fd: std.posix.fd_t, | 879 | out_fd: std.posix.fd_t, |
| 1083 | ) void { | 880 | ) void { |
| 1084 | if (chunk.len != 1) { | 881 | if (chunk.len != 1) { |
| 1085 | // An escape sequence, a multi-byte character, or a paste. None is | 882 | // An escape, a multi-byte character or a paste: none is one cell's |
| 1086 | // one cell's worth of change, and prediction speculates about none | 883 | // worth of change. Counted HERE, because a paste's lead byte is |
| 1087 | // of them. | 884 | // printable and `predictAt` would speculate on it. |
| 1088 | // The decision is made here, so the count is recorded here — a | ||
| 1089 | // paste's lead byte is printable, so handing it to predictAt would | ||
| 1090 | // predict the paste's first character instead of refusing it. | ||
| 1091 | overlay.recordSuppressed(); | 885 | overlay.recordSuppressed(); |
| 1092 | return; | 886 | return; |
| 1093 | } | 887 | } |
| @@ -1131,12 +925,9 @@ pub const predict_stats_len = 192; | |||
| 1131 | /// already talks to, rather than taking a module edge for one struct. | 925 | /// already talks to, rather than taking a module edge for one struct. |
| 1132 | pub const PredictCounters = predict.Counters; | 926 | pub const PredictCounters = predict.Counters; |
| 1133 | 927 | ||
| 1134 | /// The `MUX_PREDICT_STATS` line, on the way out. | 928 | /// The `MUX_PREDICT_STATS` line, on the way out. A tile's Core lives on a |
| 1135 | /// | 929 | /// detached pump the process exit kills where it stands, so the driver that |
| 1136 | /// A wall tile's Core lives on a detached pump the process exit kills | 930 | /// owns the exit prints it — on the normal screen, not a discarded one. |
| 1137 | /// where it stands, so the driver that owns the exit prints this from | ||
| 1138 | /// counters the pump published — on the normal screen, not an alternate | ||
| 1139 | /// one about to be discarded. | ||
| 1140 | pub fn dumpPredictStats(c: predict.Counters) void { | 931 | pub fn dumpPredictStats(c: predict.Counters) void { |
| 1141 | const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; | 932 | const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; |
| 1142 | if (!std.mem.eql(u8, want, "1")) return; | 933 | if (!std.mem.eql(u8, want, "1")) return; |
| @@ -1146,13 +937,9 @@ pub fn dumpPredictStats(c: predict.Counters) void { | |||
| 1146 | } | 937 | } |
| 1147 | 938 | ||
| 1148 | /// Turn wheel notches into the arrow keys an alt-screen application reads, | 939 | /// Turn wheel notches into the arrow keys an alt-screen application reads, |
| 1149 | /// `wheel_rows` per notch so the wheel moves the same distance either way. | 940 | /// `wheel_rows` per notch. Sent as input, never predicted — a guess at a |
| 1150 | /// | 941 | /// full-screen application's cursor is about a layout mux cannot see — and |
| 1151 | /// Sent as input rather than predicted: a guess painted at the cursor of | 942 | /// batched, since one frame per arrow would put a hundred on the wire. |
| 1152 | /// a full-screen application is about a layout the client cannot see. | ||
| 1153 | /// | ||
| 1154 | /// Batched: a spin arrives as one burst and one frame per arrow would | ||
| 1155 | /// put a hundred frames on the wire. | ||
| 1156 | fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void { | 943 | fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void { |
| 1157 | const seq = altScrollSeq(wheel, app_cursor); | 944 | const seq = altScrollSeq(wheel, app_cursor); |
| 1158 | var buf: [alt_scroll_batch * 3]u8 = undefined; | 945 | var buf: [alt_scroll_batch * 3]u8 = undefined; |
| @@ -1205,32 +992,23 @@ const Pass = enum { carry_on, skip }; | |||
| 1205 | /// What a `selection_reply` turned out to be worth — `Core.selectionCopy`'s | 992 | /// What a `selection_reply` turned out to be worth — `Core.selectionCopy`'s |
| 1206 | /// answer, and the whole of what a driver has to decide about. | 993 | /// answer, and the whole of what a driver has to decide about. |
| 1207 | pub const Copy = union(enum) { | 994 | pub const Copy = union(enum) { |
| 1208 | /// Nothing to do, and nothing to say. Somebody else's reply, a reply | 995 | /// Nothing to do, and nothing to say: somebody else's reply, one whose |
| 1209 | /// whose highlight is gone, a selection that came back empty, or a | 996 | /// highlight is gone, an empty selection, or a refusal the user cannot act |
| 1210 | /// refusal the user cannot act on: `.invalid` is this client sending | 997 | /// on. None is a sentence worth putting over their work. |
| 1211 | /// coordinates the daemon could not use, and `.unavailable` is a | ||
| 1212 | /// session with no screen to read — neither is a sentence worth | ||
| 1213 | /// putting over the user's work. | ||
| 1214 | none, | 998 | none, |
| 1215 | /// The selected text, borrowing the frame payload and valid only while | 999 | /// The selected text, borrowing the frame payload and valid only while |
| 1216 | /// it is. | 1000 | /// it is. |
| 1217 | text: []const u8, | 1001 | text: []const u8, |
| 1218 | /// `.ok`, and past what OSC 52 can carry. Said out loud rather than | 1002 | /// `.ok`, and past what OSC 52 can carry. Said out loud, because |
| 1219 | /// dropped: `client_core.validClipboard` refuses an oversized payload | 1003 | /// `appendHostEffect` refuses by writing NOTHING and a silent stop looks |
| 1220 | /// and `appendHostEffect` refuses by writing NOTHING, so a copy that | 1004 | /// exactly like a copy that worked. Never truncated: the user would find |
| 1221 | /// stopped here would look exactly like a copy that worked. Never | 1005 | /// out when they paste it. |
| 1222 | /// truncated either — half a selection is worse than a refused one, | ||
| 1223 | /// because the user finds out when they paste it. | ||
| 1224 | too_large, | 1006 | too_large, |
| 1225 | }; | 1007 | }; |
| 1226 | 1008 | ||
| 1227 | /// What one frame turned out to be, once the Core has done its half of it. | 1009 | /// What one frame turned out to be, once the Core has done its half. The |
| 1228 | /// | 1010 | /// exhaustive switch over `proto.MsgType` lives in `Core.frame` and nowhere |
| 1229 | /// The exhaustive switch over `proto.MsgType` lives in `Core.frame` and | 1011 | /// else; this is the narrow set a driver still has to act on. |
| 1230 | /// nowhere else; this is the narrow set of answers a driver still has to | ||
| 1231 | /// act on. Two drivers wrote that switch between them before this existed | ||
| 1232 | /// and they had already drifted (a short snapshot was a `continue` in one | ||
| 1233 | /// and a broken frame loop in the other). | ||
| 1234 | pub const Routed = enum { | 1012 | pub const Routed = enum { |
| 1235 | /// Done here; the driver's pass carries on. | 1013 | /// Done here; the driver's pass carries on. |
| 1236 | handled, | 1014 | handled, |
| @@ -1241,34 +1019,25 @@ pub const Routed = enum { | |||
| 1241 | /// bookkeeping off the first state of an attach — the tile narrates | 1019 | /// bookkeeping off the first state of an attach — the tile narrates |
| 1242 | /// `[up]` on its label bar. | 1020 | /// `[up]` on its label bar. |
| 1243 | state, | 1021 | state, |
| 1244 | /// The replica took state and then refused what arrived, and can no | 1022 | /// The replica took state and then refused what arrived: it can no longer |
| 1245 | /// longer be trusted. The driver answers by re-attaching from scratch, | 1023 | /// be trusted. The driver re-attaches quoting no seq and no epoch, since |
| 1246 | /// quoting no seq and no epoch — the whole problem is that what we hold | 1024 | /// what it holds is exactly what is untrusted. |
| 1247 | /// is untrusted — at whatever size its rect claims | ||
| 1248 | /// (`wallview.sendAttach`). | ||
| 1249 | resync, | 1025 | resync, |
| 1250 | /// Not the Core's. `exit_status` and `taken_over` are the session's | 1026 | /// Not the Core's: what a lifecycle frame or a session list MEANS is |
| 1251 | /// LIFECYCLE and `sessions_reply` is the daemon's session list; what | 1027 | /// entirely the driver's — an exit ends a client and merely relabels a |
| 1252 | /// each MEANS is entirely the driver's — an exit ends a client and | 1028 | /// tile — so the Core names them and stops. Agent frames join them from |
| 1253 | /// merely relabels a tile — so the Core names them and stops. The agent | 1029 | /// the other end, since the driver holds their local socket. |
| 1254 | /// channel frames join them for the same reason from the other end: the | ||
| 1255 | /// driver holds the local socket they belong to. | ||
| 1256 | /// | 1030 | /// |
| 1257 | /// `selection_reply` is here for a narrower reason and is the one the | 1031 | /// `selection_reply` is the one the driver must call BACK about: the |
| 1258 | /// driver must call BACK about: `selectionCopy` correlates it, tests the | 1032 | /// terminal it copies to is the wall's, and the tile that asked is |
| 1259 | /// watermark and refuses an oversized one, but the terminal it copies | 1033 | /// usually not the focused one. |
| 1260 | /// to is the wall's and the tile that asked is usually not the focused | ||
| 1261 | /// one. Same shape as `sessions_reply` and `end_reply`, which the | ||
| 1262 | /// driver reads on the pump that asked. | ||
| 1263 | not_mine, | 1034 | not_mine, |
| 1264 | }; | 1035 | }; |
| 1265 | 1036 | ||
| 1266 | /// Where a Core's paints are allowed to land. | 1037 | /// Where a Core's paints are allowed to land. A tile's terminal is shared |
| 1267 | /// | 1038 | /// with N stripes on N threads, so every paint asks first and the answer is |
| 1268 | /// A wall tile's terminal is shared with N stripes on N threads, so every | 1039 | /// held until it finishes: a focus move mid-paint would put one session's |
| 1269 | /// paint asks first and the answer is held until the paint is finished: a | 1040 | /// rows on another's screen. Side channels gate on `Core.claim` instead. |
| 1270 | /// focus move mid-paint would put one session's rows on another's | ||
| 1271 | /// screen. Side channels are gated on `Core.claim` instead. | ||
| 1272 | pub const Sink = struct { | 1041 | pub const Sink = struct { |
| 1273 | ctx: ?*anyopaque = null, | 1042 | ctx: ?*anyopaque = null, |
| 1274 | /// True when the Core may paint, with whatever lock makes that true | 1043 | /// True when the Core may paint, with whatever lock makes that true |
| @@ -1278,77 +1047,40 @@ pub const Sink = struct { | |||
| 1278 | end: ?*const fn (?*anyopaque) void = null, | 1047 | end: ?*const fn (?*anyopaque) void = null, |
| 1279 | }; | 1048 | }; |
| 1280 | 1049 | ||
| 1281 | /// Whether a Core holds a claim on its terminal, and therefore whether | 1050 | /// Whether a Core holds a claim on its terminal, and so whether there is a |
| 1282 | /// there is a teardown to write. | 1051 | /// teardown to write. A Core never owns a SCREEN — the driver lends one to a |
| 1283 | /// | 1052 | /// session at a time — so a claim is only what a SESSION brings. |
| 1284 | /// A Core never owns a SCREEN. The driver that owns one lends it to a | ||
| 1285 | /// session at a time (`wall_setup`), so a claim is only what a SESSION | ||
| 1286 | /// brings — the mouse modes, and whatever the session then asks this | ||
| 1287 | /// terminal for. | ||
| 1288 | /// | 1053 | /// |
| 1289 | /// It is also the flag that arms the undo, which is the load-bearing part: | 1054 | /// It ARMS the undo, which is the load-bearing part: nothing may write a mode |
| 1290 | /// nothing may write a mode to a terminal this is `.none` on, because | 1055 | /// to a terminal this is `.none` on, because `.none` is the state in which |
| 1291 | /// `.none` is exactly the state in which nothing is arranged to unset it. | 1056 | /// nothing is arranged to unset it. A session that arms bracketed paste and |
| 1292 | /// `?2004l` is the case that bites. A session that arms bracketed paste | 1057 | /// then dies never sends the frame that would clear it, so this teardown is |
| 1293 | /// and then dies never sends the `term_modes` frame that would unset it, | 1058 | /// the only `?2004l` mux is certain to write. |
| 1294 | /// so the teardown this flag gates is the only `?2004l` mux is certain to | ||
| 1295 | /// write — and a `?2004h` that went out under `.none` is one nothing will | ||
| 1296 | /// ever undo, handing the user back a terminal that eats their pastes with | ||
| 1297 | /// nothing on screen to say why. | ||
| 1298 | /// | ||
| 1299 | /// It is one flag, not an ordering, and it was a race before it was a | ||
| 1300 | /// flag: the arm used to go out unconditionally and the claim simply | ||
| 1301 | /// always won on a tty. `writeSideChannel` takes the claim and refuses | ||
| 1302 | /// everything while it is false, so the set and its undo are gated on one | ||
| 1303 | /// flag rather than on who ran first — and the title is how the hole the | ||
| 1304 | /// race left open was found; see that function. | ||
| 1305 | pub const Claim = enum { none, session }; | 1059 | pub const Claim = enum { none, session }; |
| 1306 | 1060 | ||
| 1307 | /// One session's interaction state, and everything done to a terminal on | 1061 | /// One session's interaction state and everything done to a terminal on its |
| 1308 | /// its behalf: the replica the daemon's frames are replayed into, the | 1062 | /// behalf: the replica frames are replayed into, the prediction overlay over |
| 1309 | /// prediction overlay drawn on top of it, the chord and mouse filters that | 1063 | /// it, the chord and mouse filters, and the terminal it happens on. |
| 1310 | /// split what the user types, and the terminal this all happens on. | ||
| 1311 | /// | 1064 | /// |
| 1312 | /// How you drive it. The driver owns the transport and the loop; the Core | 1065 | /// The driver owns the transport and the loop; the Core owns each event: |
| 1313 | /// owns what happens at each event: | ||
| 1314 | /// | 1066 | /// |
| 1315 | /// * `initSized` / `deinit` — the Core owns its Engine, its overlay and | 1067 | /// * `initSized` / `deinit` — the Core owns its Engine, its overlay and |
| 1316 | /// whatever terminal claim it still holds. `deinit` puts all three | 1068 | /// whatever terminal claim it still holds, and puts all three back. |
| 1317 | /// back; see there for the order. | ||
| 1318 | /// * `claimTerminal` / `releaseTerminal` — the terminal a tile BORROWS | 1069 | /// * `claimTerminal` / `releaseTerminal` — the terminal a tile BORROWS |
| 1319 | /// for as long as it is focused. Raw mode and the SIGWINCH handler | 1070 | /// while focused. Raw mode and SIGWINCH belong to the driver. |
| 1320 | /// belong to the driver that owns the screen (the wall arms both); a | 1071 | /// * per pass `idle`; per frame `frame(type, payload)`, which routes and |
| 1321 | /// Core only ever holds a session's claim. See `Claim`. | 1072 | /// returns only what is LEFT, the replica's apply included. |
| 1322 | /// * per pass: `idle`. | 1073 | /// * per read of stdin: the DRIVER's. It feeds its own `PrefixFilter` and |
| 1323 | /// * per frame: `frame(type, payload)` routes it and returns what is | 1074 | /// calls `forward` with the bytes that were not a chord. |
| 1324 | /// LEFT (see `Routed`) — the replica's apply included, because a driver | ||
| 1325 | /// that applied on its own would be a second exhaustive switch over the | ||
| 1326 | /// wire, which is what this replaced. | ||
| 1327 | /// * per read of stdin: the driver's, not the Core's. The wall's keyboard | ||
| 1328 | /// is on its own thread, so it feeds its own `PrefixFilter`, acts on | ||
| 1329 | /// the action, and calls `forward` with the bytes that were not a | ||
| 1330 | /// chord — everything below the chord layer (mouse, wheel, scrollback, | ||
| 1331 | /// prediction) is in `forward`. | ||
| 1332 | /// * around a reconnect: `dropScrollView` before, `reattached` after. | 1075 | /// * around a reconnect: `dropScrollView` before, `reattached` after. |
| 1333 | /// | 1076 | /// |
| 1334 | /// What it depends on: an Engine, a Replica, the prediction overlay, the | 1077 | /// It depends on an Engine, a Replica, the overlay, the painter and the |
| 1335 | /// painter, and the shared semantic decoder — never a transport type. Every | 1078 | /// shared decoder — never a transport type. Nothing here is a singleton: a |
| 1336 | /// method that writes takes the transport as `anytype` and calls exactly | 1079 | /// tile brings its own Core, so there is never a second applier for one tile. |
| 1337 | /// one thing on it, `writeFrame`. | ||
| 1338 | /// | ||
| 1339 | /// Nothing here is a singleton. A wall tile brings its own transport and | ||
| 1340 | /// its own Core, one per tile from birth — there is never a second replica | ||
| 1341 | /// or a second applier for one tile. `out_fd`/`size` say where a Core | ||
| 1342 | /// paints and `sink` says whether it may right now; a gone tile's says | ||
| 1343 | /// no, and the wall paints its dead-tile label instead. | ||
| 1344 | pub const Core = struct { | 1080 | pub const Core = struct { |
| 1345 | /// The tile index a `Core` selects under: it is the only | 1081 | /// The tile index a `Core` selects under: it is the only session on its |
| 1346 | /// session on its rect, so there is exactly one. Named rather than | 1082 | /// rect, so there is exactly one. Named because three sites must agree — |
| 1347 | /// written three times because the drag machine's `tile` is what | 1083 | /// `hitTest` stamps it, `highlight` passes it, `paintDragChange` reads it. |
| 1348 | /// confines a selection to one pane — `hitTest` stamps it, `highlight` | ||
| 1349 | /// hands it to the painter, and `paintDragChange` decides which rows | ||
| 1350 | /// changed with it. Three literals that must agree, and a highlight | ||
| 1351 | /// with holes in it if they ever stopped. | ||
| 1352 | const focus_tile: usize = 0; | 1084 | const focus_tile: usize = 0; |
| 1353 | 1085 | ||
| 1354 | alloc: std.mem.Allocator, | 1086 | alloc: std.mem.Allocator, |
| @@ -1378,11 +1110,9 @@ pub const Core = struct { | |||
| 1378 | /// one-tile wall owns every row; a tile among neighbours does not. Set | 1110 | /// one-tile wall owns every row; a tile among neighbours does not. Set |
| 1379 | /// by the driver that knows the layout, not inferred from `row_off`. | 1111 | /// by the driver that knows the layout, not inferred from `row_off`. |
| 1380 | owns_screen: bool = true, | 1112 | owns_screen: bool = true, |
| 1381 | /// The replay core (replica.zig). Public because the driver's own | 1113 | /// The replay core (replica.zig). Public because the driver reads it: a |
| 1382 | /// business reads it — a reconnect quotes `last_seq`/`session_epoch`, | 1114 | /// reconnect quotes `last_seq`/`session_epoch`, and `state_since_attach` |
| 1383 | /// and a refusal is told from a shell exiting by `state_since_attach`. | 1115 | /// tells a refusal from a shell exiting. The Core owns the Engine. |
| 1384 | /// The Core owns the Engine underneath it; the Replica only borrows it, | ||
| 1385 | /// so `deinit` here is the one owner. | ||
| 1386 | rep: Replica, | 1116 | rep: Replica, |
| 1387 | /// Speculative echo. Born `.never` and stays there until a daemon tells | 1117 | /// Speculative echo. Born `.never` and stays there until a daemon tells |
| 1388 | /// it otherwise, so an old daemon that has never heard of pty_mode gets | 1118 | /// it otherwise, so an old daemon that has never heard of pty_mode gets |
| @@ -1404,24 +1134,19 @@ pub const Core = struct { | |||
| 1404 | /// link carries one conversation, and `client_core` correlates against | 1134 | /// link carries one conversation, and `client_core` correlates against |
| 1405 | /// the pending one it was handed. | 1135 | /// the pending one it was handed. |
| 1406 | sel_id: u32 = 0, | 1136 | sel_id: u32 = 0, |
| 1407 | /// `history_rows` as of the request now in flight, and the whole of the | 1137 | /// `history_rows` as of the request in flight, and the whole staleness |
| 1408 | /// staleness test. Absolute rows count from the OLDEST RETAINED row, so | 1138 | /// test: an eviction between ask and answer renames the coordinate space |
| 1409 | /// an eviction between the ask and the answer renames the coordinate | 1139 | /// and yields text that is `.ok` and not what was highlighted. Only a |
| 1410 | /// space and yields text that is `.ok`, valid UTF-8, and not what was | 1140 | /// LOWER count is evidence — output raises it without moving row zero. |
| 1411 | /// highlighted. Ordinary output RAISES the count without moving row | ||
| 1412 | /// zero, so only a LOWER one is evidence (`protocol.SelectionReply`). | ||
| 1413 | sel_watermark: u32 = 0, | 1141 | sel_watermark: u32 = 0, |
| 1414 | /// The selection the request was taken from, compared against what is | 1142 | /// The selection the request was taken from, compared against what is |
| 1415 | /// still held when the answer comes back. A reply that outlived its own | 1143 | /// still held when the answer comes back. A reply that outlived its own |
| 1416 | /// highlight — a relayout, a forget, a focus move, a resync — is text for | 1144 | /// highlight — a relayout, a forget, a focus move, a resync — is text for |
| 1417 | /// rows nobody is looking at any more. | 1145 | /// rows nobody is looking at any more. |
| 1418 | sel_range: ?select.Range = null, | 1146 | sel_range: ?select.Range = null, |
| 1419 | /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N | 1147 | /// Scroll mode: 0 = live; N = the screenful whose bottom sits N rows above |
| 1420 | /// rows above live. Rows rather than pages because the wheel moves by a | 1148 | /// live. Rows, not pages: the wheel moves a few lines and the keys move a |
| 1421 | /// few lines and the keys move by a screen — one unit that expresses | 1149 | /// screen. View state, not replay state, so it stays out of the Replica. |
| 1422 | /// both, and `fetch_scrollback` already addresses absolute rows. | ||
| 1423 | /// View state, not replay state, so it stays here rather than in the | ||
| 1424 | /// Replica (which holds grid/seq/epoch/history — see replica.zig). | ||
| 1425 | scroll_rows: u32 = 0, | 1150 | scroll_rows: u32 = 0, |
| 1426 | /// What this Core holds on the terminal, and therefore what it may | 1151 | /// What this Core holds on the terminal, and therefore what it may |
| 1427 | /// write there and what it must undo. See `Claim`. | 1152 | /// write there and what it must undo. See `Claim`. |
| @@ -1430,15 +1155,9 @@ pub const Core = struct { | |||
| 1430 | /// does. Set by a driver that shares its terminal; see `Sink`. | 1155 | /// does. Set by a driver that shares its terminal; see `Sink`. |
| 1431 | sink: Sink = .{}, | 1156 | sink: Sink = .{}, |
| 1432 | /// Whether `deinit` is the right place to print the prediction stats. | 1157 | /// Whether `deinit` is the right place to print the prediction stats. |
| 1433 | /// | 1158 | /// False for a tile's Core, which lives on a detached pump: `deinit` is |
| 1434 | /// True for a Core whose driver ends by returning through it. False for | 1159 | /// not reliably reached, and when it is it prints onto a screen about to |
| 1435 | /// a wall tile's, which lives on a detached pump: the process exit | 1160 | /// be discarded and then a second time from the driver that owns the exit. |
| 1436 | /// kills that thread where it stands, so `deinit` is not reliably | ||
| 1437 | /// reached at all — and when it IS (a session that exited), it would | ||
| 1438 | /// print onto an alternate screen the wall is about to discard, and | ||
| 1439 | /// then print a second time from the driver that actually owns the | ||
| 1440 | /// exit. The wall publishes its counters instead; see | ||
| 1441 | /// `dumpPredictStats`. | ||
| 1442 | owns_stats: bool = true, | 1161 | owns_stats: bool = true, |
| 1443 | /// The first paint after a reconnect must be a full one, so the | 1162 | /// The first paint after a reconnect must be a full one, so the |
| 1444 | /// [reconnecting] banner goes away with everything else now stale. | 1163 | /// [reconnecting] banner goes away with everything else now stale. |
| @@ -1474,14 +1193,10 @@ pub const Core = struct { | |||
| 1474 | /// the driver prints on its way out land on a terminal already out of | 1193 | /// the driver prints on its way out land on a terminal already out of |
| 1475 | /// this session's modes. | 1194 | /// this session's modes. |
| 1476 | pub fn deinit(self: *Core) void { | 1195 | pub fn deinit(self: *Core) void { |
| 1477 | // Only undo what was actually claimed, and a claim is a SESSION's | 1196 | // Only undo what was claimed: the screen belongs to the driver, which |
| 1478 | // modes and nothing more: the screen belongs to the driver, which | 1197 | // is still using it. `.write` always — this is the path a pump takes |
| 1479 | // is still on it and still using it after this tile's pump has | 1198 | // when its SESSION ends while holding the terminal, so nobody else |
| 1480 | // gone. | 1199 | // wrote the release. |
| 1481 | // `.write`, always. This is the path a pump takes when its SESSION | ||
| 1482 | // ends while its tile holds the terminal — nobody moved the focus, so nobody else wrote | ||
| 1483 | // the release, and the wall goes on running with the terminal still | ||
| 1484 | // in that session's modes. | ||
| 1485 | self.releaseTerminal(.write); | 1200 | self.releaseTerminal(.write); |
| 1486 | if (self.owns_stats) dumpPredictStats(self.overlay.counters); | 1201 | if (self.owns_stats) dumpPredictStats(self.overlay.counters); |
| 1487 | self.overlay.deinit(); | 1202 | self.overlay.deinit(); |
| @@ -1493,18 +1208,11 @@ pub const Core = struct { | |||
| 1493 | self.size = size; | 1208 | self.size = size; |
| 1494 | } | 1209 | } |
| 1495 | 1210 | ||
| 1496 | /// Take the terminal for this session ALONE — the wall's claim. | 1211 | /// Take the terminal for this session ALONE. Under the SINK for ORDER, |
| 1497 | /// | 1212 | /// not painting: the wall writes the previous holder's release under the |
| 1498 | /// Taken under the SINK for ORDER, not painting: the wall writes the | 1213 | /// same lock, and a claim outside it can land AFTER that release, leaving |
| 1499 | /// previous holder's release under the same lock, before the store that | 1214 | /// modes nothing undoes. The session's own modes go on top, because a |
| 1500 | /// makes the handover visible (wallview's `setFocus`). A claim taken | 1215 | /// claim's resize is answered by `resyncSnapshot`, which carries none. |
| 1501 | /// outside it can land AFTER that release, leaving modes nothing | ||
| 1502 | /// undoes. `false` is a claim the focus moved out from under. | ||
| 1503 | /// | ||
| 1504 | /// The session's own modes go on top because a claim's resize is | ||
| 1505 | /// answered by `resyncSnapshot`, which carries no modes: otherwise a | ||
| 1506 | /// tile whose application asked for the mouse would hold a terminal | ||
| 1507 | /// that never heard about it. | ||
| 1508 | pub fn claimTerminal(self: *Core) bool { | 1216 | pub fn claimTerminal(self: *Core) bool { |
| 1509 | if (!self.is_tty or self.claim != .none) return false; | 1217 | if (!self.is_tty or self.claim != .none) return false; |
| 1510 | if (!self.beginPaint()) return false; | 1218 | if (!self.beginPaint()) return false; |
| @@ -1526,42 +1234,25 @@ pub const Core = struct { | |||
| 1526 | return true; | 1234 | return true; |
| 1527 | } | 1235 | } |
| 1528 | 1236 | ||
| 1529 | /// Who writes the undo when a claim is given up. | 1237 | /// Who writes the undo when a claim is given up. `.already_written` |
| 1530 | /// | 1238 | /// exists because a shared terminal's handover must be ORDERED: the wall |
| 1531 | /// `.write` is the ordinary answer and the only one a Core can reach on | 1239 | /// writes the release on the thread that moves the focus. An argument |
| 1532 | /// its own. `.already_written` exists because a shared terminal's | 1240 | /// rather than a second method, so every call site has to say which — |
| 1533 | /// handover has to be ORDERED, not merely eventual: the wall's focus | 1241 | /// the wrong one is a terminal left reporting clicks. |
| 1534 | /// change writes the release itself, on the thread that moves the | ||
| 1535 | /// focus, before the store that lets the next tile's pump see the move. | ||
| 1536 | /// The outgoing pump then finds the bytes already gone and | ||
| 1537 | /// has only its own state left to drop. Spelled as an argument rather | ||
| 1538 | /// than a second method so every call site has to say which it is — | ||
| 1539 | /// silently taking the wrong one is a terminal left reporting clicks, | ||
| 1540 | /// or two releases racing a claim. | ||
| 1541 | pub const Undo = enum { write, already_written }; | 1242 | pub const Undo = enum { write, already_written }; |
| 1542 | 1243 | ||
| 1543 | /// Give the terminal back: the release, and every other way out. | 1244 | /// Give the terminal back: the release, and every other way out. Nothing |
| 1544 | /// | 1245 | /// goes on the WIRE, but plenty comes off the terminal — a wall left |
| 1545 | /// Nothing goes on the WIRE — the release is client-local — but plenty | 1246 | /// reporting clicks into the user's shell is what this pairs against. |
| 1546 | /// comes off the terminal, because the session that held it set | 1247 | /// The scroll view goes with it either way, or the resuming stripe comes |
| 1547 | /// modes on it. A wall left still reporting clicks into the user's | 1248 | /// back showing nothing. Idempotent, and the teardown follows the claim. |
| 1548 | /// shell is the failure this pairs against. | ||
| 1549 | /// | ||
| 1550 | /// The scroll view goes with it whichever way the undo went: the | ||
| 1551 | /// stripe that resumes paints live state from the same replica, so a | ||
| 1552 | /// Core still suppressing paints would come back to a tile showing | ||
| 1553 | /// nothing. | ||
| 1554 | /// | ||
| 1555 | /// Idempotent, and it says which teardown by what was claimed. | ||
| 1556 | pub fn releaseTerminal(self: *Core, undo: Undo) void { | 1249 | pub fn releaseTerminal(self: *Core, undo: Undo) void { |
| 1557 | const held = self.claim; | 1250 | const held = self.claim; |
| 1558 | self.claim = .none; | 1251 | self.claim = .none; |
| 1559 | self.dropScrollView(); | 1252 | self.dropScrollView(); |
| 1560 | // The highlight goes with the screen it was drawn on. A tile that | 1253 | // The highlight goes with the screen it was drawn on: kept, it would |
| 1561 | // lost focus paints nothing, so an inversion kept here would be | 1254 | // reappear over rows chosen in another session's lifetime, and a reply |
| 1562 | // invisible until the tile was focused again and then reappear over rows the | 1255 | // still in flight would copy that text. |
| 1563 | // user chose in another session's lifetime — and a reply still in | ||
| 1564 | // flight would copy text for them. | ||
| 1565 | self.drag.clear(); | 1256 | self.drag.clear(); |
| 1566 | if (undo == .already_written) return; | 1257 | if (undo == .already_written) return; |
| 1567 | switch (held) { | 1258 | switch (held) { |
| @@ -1603,11 +1294,8 @@ pub const Core = struct { | |||
| 1603 | } | 1294 | } |
| 1604 | 1295 | ||
| 1605 | /// The whole screen from the replica — a local repaint at zero round | 1296 | /// The whole screen from the replica — a local repaint at zero round |
| 1606 | /// trips, the replica hot the whole time the tile has been painting its | 1297 | /// trips. The overlay goes back on top, because the rows just drawn have |
| 1607 | /// rect. | 1298 | /// overwritten predictions still outstanding. |
| 1608 | /// | ||
| 1609 | /// The overlay goes back on top: the rows just drawn have overwritten | ||
| 1610 | /// predictions still outstanding. | ||
| 1611 | pub fn repaint(self: *Core) !void { | 1299 | pub fn repaint(self: *Core) !void { |
| 1612 | if (!self.beginPaint()) return; | 1300 | if (!self.beginPaint()) return; |
| 1613 | defer self.endPaint(); | 1301 | defer self.endPaint(); |
| @@ -1625,28 +1313,15 @@ pub const Core = struct { | |||
| 1625 | return .{ .x = c.x + vp.left, .y = c.y + vp.top }; | 1313 | return .{ .x = c.x + vp.left, .y = c.y + vp.top }; |
| 1626 | } | 1314 | } |
| 1627 | 1315 | ||
| 1628 | /// Repaint the rows a drag report changed, and only those. | 1316 | /// Repaint the rows a drag report changed, and only those: the anchor |
| 1629 | /// | 1317 | /// does not move, so only the rows between the two ends can change. Every |
| 1630 | /// The anchor does not move, so a report that moved the active end | 1318 | /// row is still ASKED rather than reasoned about — a wrong answer is a |
| 1631 | /// changes the span on the rows between the two ends and on no others. | 1319 | /// highlight with a hole in it. Painting the whole screen instead cost |
| 1632 | /// Every row is still asked rather than reasoned about: the question is | 1320 | /// 4.8 KB a cell at 120x40. The overlay goes back on top, as in `repaint`. |
| 1633 | /// arithmetic, and a wrong answer here is a highlight with a hole in it. | ||
| 1634 | /// | ||
| 1635 | /// Painting the whole screen instead cost the screen's bytes for every | ||
| 1636 | /// cell the pointer crossed — 4.8 KB a cell at 120x40, so 288 KB to | ||
| 1637 | /// drag across sixty of them. | ||
| 1638 | /// | ||
| 1639 | /// The overlay goes back on top for the same reason `repaint` puts it | ||
| 1640 | /// there: a drag abandons no predictions, so any still outstanding have | ||
| 1641 | /// to survive the rows this redrew. | ||
| 1642 | fn paintDragChange(self: *Core, was: ?select.Range) !void { | 1321 | fn paintDragChange(self: *Core, was: ?select.Range) !void { |
| 1643 | // Scroll mode owns the screen: `renderScrollback` blits the | 1322 | // Scroll mode owns the screen: `renderScrollback` blits VT bytes with |
| 1644 | // daemon's VT bytes with no engine behind them, so a row painted | 1323 | // no engine behind them, so a row from the live replica lands on a |
| 1645 | // from the live replica lands on a page this Core cannot address. | 1324 | // page this Core cannot address. Leaving scroll mode repaints in full. |
| 1646 | // Reachable in three actions — select, wheel up, click — because | ||
| 1647 | // `hitTest` refuses while scrolled and a press with no hit CLEARS | ||
| 1648 | // the held range, which is a change like any other. Leaving scroll | ||
| 1649 | // mode repaints in full, so the highlight comes back either way. | ||
| 1650 | if (self.scroll_rows > 0) return; | 1325 | if (self.scroll_rows > 0) return; |
| 1651 | const now = self.drag.range(); | 1326 | const now = self.drag.range(); |
| 1652 | if (std.meta.eql(was, now)) return; | 1327 | if (std.meta.eql(was, now)) return; |
| @@ -1705,11 +1380,9 @@ pub const Core = struct { | |||
| 1705 | } | 1380 | } |
| 1706 | } | 1381 | } |
| 1707 | 1382 | ||
| 1708 | /// The replica has taken a snapshot: tell the overlay, rebuild under | 1383 | /// The replica has taken a snapshot: tell the overlay, rebuild under it. |
| 1709 | /// it. | 1384 | /// A snapshot answers a resize and ends a reconnect; neither says a |
| 1710 | /// | 1385 | /// prediction was WRONG, only that we can no longer find out — so the |
| 1711 | /// A snapshot answers a resize and ends a reconnect. Neither says a | ||
| 1712 | /// prediction was wrong — it says we can no longer find out, so the | ||
| 1713 | /// queue goes and the counters do not move. | 1386 | /// queue goes and the counters do not move. |
| 1714 | fn snapshotTaken(self: *Core) !void { | 1387 | fn snapshotTaken(self: *Core) !void { |
| 1715 | self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows); | 1388 | self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows); |
| @@ -1740,22 +1413,12 @@ pub const Core = struct { | |||
| 1740 | if (self.scroll_rows == 0 and self.beginPaint()) { | 1413 | if (self.scroll_rows == 0 and self.beginPaint()) { |
| 1741 | defer self.endPaint(); | 1414 | defer self.endPaint(); |
| 1742 | if (self.repaint_after_resync or verdict == .contradicted) { | 1415 | if (self.repaint_after_resync or verdict == .contradicted) { |
| 1743 | // First frame back after a reconnect. The daemon sent only | 1416 | // First frame back after a reconnect: the daemon sent only |
| 1744 | // what changed, which is correct — but the screen still | 1417 | // what changed, but the screen still carries the banner. A |
| 1745 | // carries the banner, so repaint the whole thing from the | 1418 | // contradiction takes the same route — the queue has just been |
| 1746 | // replica instead. | 1419 | // abandoned, and a full repaint is the rollback that is |
| 1747 | // | 1420 | // certainly right. Painted raw, not through `paintFull`: this |
| 1748 | // A contradiction (or an expiry) takes the same route: the | 1421 | // and the overlay below are ONE hold of a non-reentrant sink. |
| 1749 | // whole queue has just been abandoned, and repainting | ||
| 1750 | // everything from the replica is the simplest rollback that | ||
| 1751 | // is certainly right. It is affordable precisely because | ||
| 1752 | // reconcile v2 made contradictions rare — a burst outrunning | ||
| 1753 | // the round trip is no longer one. | ||
| 1754 | // | ||
| 1755 | // Painted raw rather than through `paintFull`: this arm and | ||
| 1756 | // the overlay below it are ONE hold of the sink (a focus move | ||
| 1757 | // between them would put the two halves of this frame | ||
| 1758 | // on two different screens), and the sink is not reentrant. | ||
| 1759 | const hl = self.highlight(); | 1422 | const hl = self.highlight(); |
| 1760 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd); | 1423 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd); |
| 1761 | self.repaint_after_resync = false; | 1424 | self.repaint_after_resync = false; |
| @@ -1770,11 +1433,9 @@ pub const Core = struct { | |||
| 1770 | } | 1433 | } |
| 1771 | } | 1434 | } |
| 1772 | 1435 | ||
| 1773 | /// The session handed the terminal back and forth, or took it over. | 1436 | /// The session handed the terminal back and forth, or took it over. Mode |
| 1774 | /// | 1437 | /// churn is ordinary — readline does it around every command — so the |
| 1775 | /// Mode churn is ordinary — readline does it around every command — so | 1438 | /// repaint is spent only when the flush took something off the screen. |
| 1776 | /// the repaint is spent only when the flush actually took something off | ||
| 1777 | /// the screen. | ||
| 1778 | fn ptyModeChanged(self: *Core, payload: []const u8) !Pass { | 1439 | fn ptyModeChanged(self: *Core, payload: []const u8) !Pass { |
| 1779 | const flags = proto.decodePtyMode(payload) catch return .skip; | 1440 | const flags = proto.decodePtyMode(payload) catch return .skip; |
| 1780 | const had_pending = self.overlay.pendingCount() > 0; | 1441 | const had_pending = self.overlay.pendingCount() > 0; |
| @@ -1798,22 +1459,14 @@ pub const Core = struct { | |||
| 1798 | return .carry_on; | 1459 | return .carry_on; |
| 1799 | } | 1460 | } |
| 1800 | 1461 | ||
| 1801 | /// A term_event or term_modes frame: decoded by the shared core, then | 1462 | /// A `term_event` or `term_modes` frame: decoded by the shared core, then |
| 1802 | /// rendered onto the host terminal by this platform's adapters. | 1463 | /// rendered by this platform's adapters. The DECODE happens whatever the |
| 1803 | /// | 1464 | /// sink says and the WRITE does not — an unfocused tile tracks its |
| 1804 | /// The decode happens whatever the sink says and the WRITE does not, | 1465 | /// session's modes for the claim that will need them. |
| 1805 | /// which is the same split the claim already makes: a wall tile that is not focused | ||
| 1806 | /// tracks its session's modes for the focus claim that will need them (see | ||
| 1807 | /// `claimTerminal`) and puts nothing on a terminal it does not hold. | ||
| 1808 | /// | 1466 | /// |
| 1809 | /// Under the sink for a reason the claim gate does NOT cover, and the | 1467 | /// The claim answers ORDER, the sink answers ATOMICITY: a 64 KiB OSC 52 |
| 1810 | /// two are worth keeping apart. The claim answers ORDER — may these | 1468 | /// payload leaves here as a write loop, and another thread's screen clear |
| 1811 | /// bytes exist on this terminal at all. The sink answers ATOMICITY — an | 1469 | /// spliced into it leaves the terminal hunting for a string terminator. |
| 1812 | /// OSC 52 clipboard payload runs to 64 KiB and leaves here as a write | ||
| 1813 | /// loop, so another thread's screen clear spliced into the middle of it | ||
| 1814 | /// leaves the terminal hunting for a string terminator and eating | ||
| 1815 | /// everything painted after. Short escapes were never the hazard; the | ||
| 1816 | /// one unbounded side channel is. | ||
| 1817 | fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void { | 1470 | fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void { |
| 1818 | const decoded = self.semantic.receive(frame_type, payload); | 1471 | const decoded = self.semantic.receive(frame_type, payload); |
| 1819 | if (!self.beginPaint()) return; | 1472 | if (!self.beginPaint()) return; |
| @@ -1821,12 +1474,9 @@ pub const Core = struct { | |||
| 1821 | switch (decoded) { | 1474 | switch (decoded) { |
| 1822 | .ignored => {}, | 1475 | .ignored => {}, |
| 1823 | .state => |state| { | 1476 | .state => |state| { |
| 1824 | // Mode samples are deliberately not deduplicated. | 1477 | // Mode samples are deliberately not deduplicated: reasserting |
| 1825 | // sendResync repeats the current level on attach, reconnect | 1478 | // DECSET/DECRST 2004 is a harmless level-set that restores the |
| 1826 | // and forced re-attach; reasserting DECSET/DECRST 2004 is a | 1479 | // host after a new connection. Occurrences take the arm below. |
| 1827 | // harmless level-set and restores the host after a new | ||
| 1828 | // connection. Occurrence effects take the separate arm below | ||
| 1829 | // and are never covered by this repeat policy. | ||
| 1830 | try writeSideChannel( | 1480 | try writeSideChannel( |
| 1831 | self.alloc, | 1481 | self.alloc, |
| 1832 | self.out_fd, | 1482 | self.out_fd, |
| @@ -1846,21 +1496,15 @@ pub const Core = struct { | |||
| 1846 | ), | 1496 | ), |
| 1847 | // Nothing to write: a `.reply` answers something this client | 1497 | // Nothing to write: a `.reply` answers something this client |
| 1848 | // ASKED for, and the one there is goes out through | 1498 | // ASKED for, and the one there is goes out through |
| 1849 | // `selectionCopy`, which the driver calls with the selection | 1499 | // `selectionCopy`. Named, so a second kind of reply is an edit here. |
| 1850 | // still on screen. Nothing routes a reply here. Named rather | ||
| 1851 | // than swept so that a second kind of reply is an edit here. | ||
| 1852 | .reply => {}, | 1500 | .reply => {}, |
| 1853 | } | 1501 | } |
| 1854 | } | 1502 | } |
| 1855 | 1503 | ||
| 1856 | /// The session's window title. | 1504 | /// The session's window title. Repeats are expected — every attach |
| 1857 | /// | 1505 | /// resends it — and harmless: setting a title to the value it already |
| 1858 | /// Repeats are expected here for the same reason as term_modes above — | 1506 | /// holds has no counter or stack behind it, and the daemon never sends |
| 1859 | /// every attach resends the title — and are harmless for a stronger | 1507 | /// an empty one, so a repeat cannot clear what the user is looking at. |
| 1860 | /// reason: setting a window title to the value it already holds is a | ||
| 1861 | /// no-op with no counter or stack behind it. Note the daemon never | ||
| 1862 | /// sends an empty one, so a repeat can never clear a title the user is | ||
| 1863 | /// looking at. | ||
| 1864 | fn titleFrame(self: *Core, payload: []const u8) !void { | 1508 | fn titleFrame(self: *Core, payload: []const u8) !void { |
| 1865 | // Under the sink, `semanticFrame`'s atomicity reason: a title is | 1509 | // Under the sink, `semanticFrame`'s atomicity reason: a title is |
| 1866 | // bounded at `term_title_max` but it is still an OSC with a | 1510 | // bounded at `term_title_max` but it is still an OSC with a |
| @@ -1877,23 +1521,13 @@ pub const Core = struct { | |||
| 1877 | ); | 1521 | ); |
| 1878 | } | 1522 | } |
| 1879 | 1523 | ||
| 1880 | /// One frame from the daemon, routed. The exhaustive switch over the | 1524 | /// One frame from the daemon, routed. The exhaustive switch over the wire |
| 1881 | /// wire lives HERE and nowhere else. | 1525 | /// lives HERE and nowhere else — including the replica's apply, because a |
| 1882 | /// | 1526 | /// driver applying on its own is a second switch over the same enum. |
| 1883 | /// It applies to the replica too, which is the part worth defending: a | ||
| 1884 | /// driver that applied on its own would need to know which types the | ||
| 1885 | /// replica takes, which is a second switch over the same enum — and the | ||
| 1886 | /// two that existed before this had already drifted, one treating a | ||
| 1887 | /// short snapshot as a `continue` and the other as a broken frame loop. | ||
| 1888 | /// The Core owns the replica (`rep`), so the Core feeds it. | ||
| 1889 | /// | ||
| 1890 | /// What comes back is only what is LEFT (see `Routed`): the driver's | ||
| 1891 | /// own bookkeeping on first state, its re-attach spelling on a resync, | ||
| 1892 | /// and the frames that are nobody's but the driver's (`.not_mine`). | ||
| 1893 | /// | 1527 | /// |
| 1894 | /// Errors are the replica's, unchanged: a snapshot too short to read is | 1528 | /// What comes back is only what is LEFT (see `Routed`). Errors are the |
| 1895 | /// `.skip` because `readSnapshotPrefix` left everything untouched, and | 1529 | /// replica's, unchanged: a snapshot too short to read is `.skip`, since |
| 1896 | /// anything else out of a resize stays loud. | 1530 | /// `readSnapshotPrefix` left everything untouched. |
| 1897 | pub fn frame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !Routed { | 1531 | pub fn frame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !Routed { |
| 1898 | switch (frame_type) { | 1532 | switch (frame_type) { |
| 1899 | .snapshot => { | 1533 | .snapshot => { |
| @@ -1939,12 +1573,9 @@ pub const Core = struct { | |||
| 1939 | // an agent channel is never the Core's: these bytes go to a | 1573 | // an agent channel is never the Core's: these bytes go to a |
| 1940 | // socket, not the grid. | 1574 | // socket, not the grid. |
| 1941 | .agent_open, .agent_data, .agent_close => return .not_mine, | 1575 | .agent_open, .agent_data, .agent_close => return .not_mine, |
| 1942 | // Named rather than swept into the `else`, so that adding a | 1576 | // Named rather than swept into the `else`, so giving one a meaning |
| 1943 | // meaning for one of them is an edit here and not a new switch | 1577 | // is an edit here and not a new switch elsewhere. Each belongs to |
| 1944 | // somewhere else. Every one is either part of `mux a`'s | 1578 | // `mux a`'s conversation or travels the other way. |
| 1945 | // conversation with the daemon (its replies, and the | ||
| 1946 | // `cmd_state` pushes it subscribes to) or a frame that travels | ||
| 1947 | // the other way. | ||
| 1948 | .stats_reply, | 1579 | .stats_reply, |
| 1949 | .endpoint_reply, | 1580 | .endpoint_reply, |
| 1950 | .cmd_state, | 1581 | .cmd_state, |
| @@ -1980,23 +1611,12 @@ pub const Core = struct { | |||
| 1980 | /// the mouse split, alternate scroll, the scrollback view, and finally | 1611 | /// the mouse split, alternate scroll, the scrollback view, and finally |
| 1981 | /// the prediction and the input frame. | 1612 | /// the prediction and the input frame. |
| 1982 | pub fn forward(self: *Core, transport: anytype, typed: []const u8) !Step { | 1613 | pub fn forward(self: *Core, transport: anytype, typed: []const u8) !Step { |
| 1983 | // Who the wheel belongs to is the session's to say, and it says so | 1614 | // Who the wheel belongs to is the SESSION's to say: an application |
| 1984 | // in the modes it set: an application that asked for mouse | 1615 | // that asked for mouse reporting gets every mouse byte verbatim, and |
| 1985 | // reporting gets every mouse byte verbatim, and the filter is reset | 1616 | // the filter resets so a report straddling the handover is not |
| 1986 | // so a report straddling the handover is not half-eaten. This is | 1617 | // half-eaten. The claim gates it for a second reason — a client whose |
| 1987 | // the same rule as bracketed paste — the client mirrors what the | 1618 | // stdin is a PIPE never asked for mouse reports, so nothing it reads |
| 1988 | // session asked the terminal for — applied to the one device the | 1619 | // can be one, and `\x1b[<64;10;5M` in a heredoc is text to send. |
| 1989 | // client also has a use for. | ||
| 1990 | // | ||
| 1991 | // The claim gates it for a second reason, and it is the one that | ||
| 1992 | // bites hardest: it is the flag that says this Core took a terminal | ||
| 1993 | // over and wrote `client_mouse_setup` to it. A client whose stdin | ||
| 1994 | // is a PIPE never asked anyone for mouse reports, so nothing it | ||
| 1995 | // reads can be one — and filtering there is pure loss: the bytes | ||
| 1996 | // are whatever was piped in, and `\x1b[<64;10;5M` in a heredoc is | ||
| 1997 | // text a script meant to send. Measured: | ||
| 1998 | // `printf 'hello \x1b[<64;10;5M world\n' | mux` arrived at the pty | ||
| 1999 | // with the escape deleted. | ||
| 2000 | var keys = typed; | 1620 | var keys = typed; |
| 2001 | var wheel: i32 = 0; | 1621 | var wheel: i32 = 0; |
| 2002 | // A selection this read finished, asked for below rather than here | 1622 | // A selection this read finished, asked for below rather than here |
| @@ -2009,13 +1629,10 @@ pub const Core = struct { | |||
| 2009 | const was = self.drag.range(); | 1629 | const was = self.drag.range(); |
| 2010 | if (self.claim == .none or self.semantic.terminal_modes.appMouse()) { | 1630 | if (self.claim == .none or self.semantic.terminal_modes.appMouse()) { |
| 2011 | self.mouse.reset(); | 1631 | self.mouse.reset(); |
| 2012 | // The application asked for the mouse, so the application gets | 1632 | // The application asked for the mouse, so it gets the drag and |
| 2013 | // the drag and there is no mux selection here — the same rule | 1633 | // there is no mux selection; Shift+drag stays the terminal's own |
| 2014 | // the mode mirror already follows, and tmux's on | 1634 | // escape hatch. CLEARED, not ignored: a selection made before the |
| 2015 | // `mouse_any_flag`. Shift+drag stays the terminal's own escape | 1635 | // ask would sit inverted on a screen that is no longer its own. |
| 2016 | // hatch. Cleared rather than merely ignored: a selection made | ||
| 2017 | // before the application asked would otherwise sit inverted on | ||
| 2018 | // a screen that no longer belongs to it. | ||
| 2019 | self.drag.clear(); | 1636 | self.drag.clear(); |
| 2020 | } else { | 1637 | } else { |
| 2021 | const m = self.mouse.feed(typed, &self.mouse_buf); | 1638 | const m = self.mouse.feed(typed, &self.mouse_buf); |
| @@ -2034,21 +1651,12 @@ pub const Core = struct { | |||
| 2034 | // ask goes out from where it stands — no relay, unlike the wall's. | 1651 | // ask goes out from where it stands — no relay, unlike the wall's. |
| 2035 | if (copy) |r| self.requestSelection(transport, r) catch return .lost; | 1652 | if (copy) |r| self.requestSelection(transport, r) catch return .lost; |
| 2036 | 1653 | ||
| 2037 | // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's | 1654 | // Alternate scroll. The alt screen has no scrollback, so the rows this |
| 2038 | // own version). The alt screen has no scrollback — `historyRows` | 1655 | // notch would move do not exist — and a pager that did not ask for the |
| 2039 | // returns 0 there by contract, so the rows this notch would move do | 1656 | // mouse is still what the wheel is pointed at, so the notch becomes |
| 2040 | // not exist — and a pager that did not ask for the mouse is still | 1657 | // arrow keys it understands. Without this the notch is consumed and |
| 2041 | // the thing the wheel is pointed at. So the notch becomes the arrow | 1658 | // dropped. Only at the live view: a client already scrolled into |
| 2042 | // keys the pager does understand. | 1659 | // history owns its wheel. |
| 2043 | // | ||
| 2044 | // Without this the notch is CONSUMED and dropped: the filter has | ||
| 2045 | // already taken the bytes out, and the scroll arithmetic below | ||
| 2046 | // saturates at a history of zero. That was measured on `less`, | ||
| 2047 | // which is exactly the case this exists for. | ||
| 2048 | // | ||
| 2049 | // Only at the live view: a client scrolled into history before the | ||
| 2050 | // session took the alt screen still owns its wheel, and moving that | ||
| 2051 | // view is what a notch there means. | ||
| 2052 | if (wheel != 0 and self.scroll_rows == 0 and self.rep.eng.onAltScreen()) { | 1660 | if (wheel != 0 and self.scroll_rows == 0 and self.rep.eng.onAltScreen()) { |
| 2053 | sendAltScroll(transport, wheel, self.rep.eng.cursorKeys()) catch return .lost; | 1661 | sendAltScroll(transport, wheel, self.rep.eng.cursorKeys()) catch return .lost; |
| 2054 | wheel = 0; // spent on the session, not on our view | 1662 | wheel = 0; // spent on the session, not on our view |
| @@ -2074,11 +1682,10 @@ pub const Core = struct { | |||
| 2074 | @min(self.scroll_rows +| @as(u32, @intCast(by)), self.rep.history_rows) | 1682 | @min(self.scroll_rows +| @as(u32, @intCast(by)), self.rep.history_rows) |
| 2075 | else | 1683 | else |
| 2076 | self.scroll_rows -| @as(u32, @intCast(-by)); | 1684 | self.scroll_rows -| @as(u32, @intCast(-by)); |
| 2077 | // Shift+PageDown returns to live even when it was already | 1685 | // Shift+PageDown returns to live even when already there: it is |
| 2078 | // there, and that is load-bearing: it is the only key that | 1686 | // the only key that clears an overlay left in scroll mode by a |
| 2079 | // clears an overlay left in scroll mode by a reconnect (see | 1687 | // reconnect. The wheel has no such arm — a notch at the live view |
| 2080 | // `dropScrollView`). The wheel does not get that arm — a notch | 1688 | // would repaint the whole screen for nothing. |
| 2081 | // at the live view would repaint the whole screen for nothing. | ||
| 2082 | if (next == 0 and (self.scroll_rows > 0 or key_dn)) { | 1689 | if (next == 0 and (self.scroll_rows > 0 or key_dn)) { |
| 2083 | self.scroll_rows = 0; | 1690 | self.scroll_rows = 0; |
| 2084 | self.overlay.setScrollMode(false); | 1691 | self.overlay.setScrollMode(false); |
| @@ -2096,26 +1703,18 @@ pub const Core = struct { | |||
| 2096 | // notch owes the pty nothing. | 1703 | // notch owes the pty nothing. |
| 2097 | if (keys.len == 0 or key_up or key_dn) return .ok; | 1704 | if (keys.len == 0 or key_up or key_dn) return .ok; |
| 2098 | if (self.scroll_rows > 0 and !was_live) { | 1705 | if (self.scroll_rows > 0 and !was_live) { |
| 2099 | // Any other key exits scroll mode (swallowed, not forwarded). | 1706 | // Any other key exits scroll mode, swallowed rather than |
| 2100 | // | 1707 | // forwarded. `was_live` keeps that honest when a notch and a |
| 2101 | // `was_live` is what keeps that rule honest when a notch and a | 1708 | // keystroke share one read: a key typed before the wheel moved the |
| 2102 | // keystroke share one read. The exit rule is about a key typed | 1709 | // view was typed at the SHELL, and swallowing it loses input. |
| 2103 | // AT a history view; a key typed at the live view milliseconds | ||
| 2104 | // before the wheel moved it was typed at the shell, and | ||
| 2105 | // swallowing it loses input to a view the user was not looking | ||
| 2106 | // at yet. Both hands are answered instead: the view moved, and | ||
| 2107 | // the keystroke goes where it was aimed. | ||
| 2108 | self.scroll_rows = 0; | 1710 | self.scroll_rows = 0; |
| 2109 | self.overlay.setScrollMode(false); | 1711 | self.overlay.setScrollMode(false); |
| 2110 | try self.paintFull(); | 1712 | try self.paintFull(); |
| 2111 | } else { | 1713 | } else { |
| 2112 | // Speculate before sending, so the glyph is on screen while the | 1714 | // Speculate before sending, so the glyph is on screen while the |
| 2113 | // keystroke is still in flight. The bytes that go out are | 1715 | // keystroke is in flight. The bytes go out whatever the sink says |
| 2114 | // unchanged either way — and they go out whatever the sink | 1716 | // — a tile that lost focus still owes its session what was typed |
| 2115 | // says: a wall tile that lost focus still owes its session the bytes | 1717 | // at it — but an overlay glyph would be graffiti on the new holder. |
| 2116 | // that were typed at it while it held the terminal, it just may not | ||
| 2117 | // draw them. An overlay glyph it painted would be graffiti on | ||
| 2118 | // whichever session holds the terminal now. | ||
| 2119 | if (self.beginPaint()) { | 1718 | if (self.beginPaint()) { |
| 2120 | defer self.endPaint(); | 1719 | defer self.endPaint(); |
| 2121 | offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.viewport(), self.out_fd); | 1720 | offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.viewport(), self.out_fd); |
| @@ -2127,15 +1726,10 @@ pub const Core = struct { | |||
| 2127 | return .ok; | 1726 | return .ok; |
| 2128 | } | 1727 | } |
| 2129 | 1728 | ||
| 2130 | /// Left button only. Middle is the terminal's own paste and right its | 1729 | /// Left button only: middle is the terminal's own paste and right its |
| 2131 | /// menu. | 1730 | /// menu. A plain click is a defined no-op — a tile is the only session on |
| 2132 | /// | 1731 | /// its rect. What comes back is the selection a release FINISHED, and one |
| 2133 | /// A plain click is a defined no-op: a tile is the only session | 1732 | /// read can hold several: the last is the answer, as latest-wins says. |
| 2134 | /// on its rect, so there is nothing for a click to select. | ||
| 2135 | /// | ||
| 2136 | /// What comes back is the selection a release FINISHED. One read can | ||
| 2137 | /// hold more than one release; the last is the answer, which is what | ||
| 2138 | /// `client_core.beginSelection`'s latest-wins would make of two. | ||
| 2139 | fn dragReports(self: *Core, events: []const MouseFilter.Event) ?select.Range { | 1733 | fn dragReports(self: *Core, events: []const MouseFilter.Event) ?select.Range { |
| 2140 | var done: ?select.Range = null; | 1734 | var done: ?select.Range = null; |
| 2141 | for (events) |ev| { | 1735 | for (events) |ev| { |
| @@ -2153,13 +1747,9 @@ pub const Core = struct { | |||
| 2153 | return done; | 1747 | return done; |
| 2154 | } | 1748 | } |
| 2155 | 1749 | ||
| 2156 | /// Ask the daemon for the text under a finished selection. | 1750 | /// Ask the daemon for the text under a finished selection. Called from |
| 2157 | /// | 1751 | /// `forward`, on the focused tile's pump — the thread that owns this |
| 2158 | /// Called from `forward`, which already runs on the focused tile's | 1752 | /// transport. The wall's KEYBOARD may not: it posts the range instead. |
| 2159 | /// pump — the thread that owns this transport. The WALL's | ||
| 2160 | /// keyboard may not: a `Transport` has exactly one owning thread, so it | ||
| 2161 | /// posts the range and its pump calls this instead | ||
| 2162 | /// (`wallview`'s `copySelection`). | ||
| 2163 | pub fn requestSelection(self: *Core, transport: anytype, r: select.Range) !void { | 1753 | pub fn requestSelection(self: *Core, transport: anytype, r: select.Range) !void { |
| 2164 | self.sel_id +%= 1; | 1754 | self.sel_id +%= 1; |
| 2165 | // Sampled HERE, from the replica the coordinates were resolved | 1755 | // Sampled HERE, from the replica the coordinates were resolved |
| @@ -2174,12 +1764,9 @@ pub const Core = struct { | |||
| 2174 | try transport.writeFrame(.selection_req, &req); | 1764 | try transport.writeFrame(.selection_req, &req); |
| 2175 | } | 1765 | } |
| 2176 | 1766 | ||
| 2177 | /// What a `selection_reply` is worth, given the selection that is still | 1767 | /// What a `selection_reply` is worth, given the selection still held. The |
| 2178 | /// held — `Core.drag`, read by the caller under its lock, which is why | 1768 | /// caller supplies the range because it reads `Core.drag` under its lock. |
| 2179 | /// it supplies the range rather than this reading one. | 1769 | /// Correlation is `client_core`'s: a reply answering no pending request is |
| 2180 | /// | ||
| 2181 | /// The correlation is `client_core`'s, unchanged: a reply that answers | ||
| 2182 | /// no pending request, or answers one this drag already replaced, is | ||
| 2183 | /// `.ignored` there and never reaches the tests below. | 1770 | /// `.ignored` there and never reaches the tests below. |
| 2184 | pub fn selectionCopy(self: *Core, payload: []const u8, held: ?select.Range) Copy { | 1771 | pub fn selectionCopy(self: *Core, payload: []const u8, held: ?select.Range) Copy { |
| 2185 | const reply = switch (self.semantic.receive(.selection_reply, payload)) { | 1772 | const reply = switch (self.semantic.receive(.selection_reply, payload)) { |
| @@ -2199,35 +1786,25 @@ pub const Core = struct { | |||
| 2199 | .invalid, .unavailable => return .none, | 1786 | .invalid, .unavailable => return .none, |
| 2200 | } | 1787 | } |
| 2201 | if (reply.text.len == 0) return .none; | 1788 | if (reply.text.len == 0) return .none; |
| 2202 | // The gap this feature could have shipped into: the daemon will | 1789 | // The daemon sends up to `selection_text_max` (1 MiB) and OSC 52 stops |
| 2203 | // send up to `selection_text_max` (1 MiB) and OSC 52 stops at | 1790 | // at `clipboard_base64_max` (64 KiB) of base64: everything between the |
| 2204 | // `clipboard_base64_max` (64 KiB) of BASE64, four bytes per three. | 1791 | // two round-trips `.ok` and copies nothing at all. |
| 2205 | // Everything between the two would round-trip `.ok` and copy | ||
| 2206 | // nothing at all. | ||
| 2207 | if (std.base64.standard.Encoder.calcSize(reply.text.len) > proto.clipboard_base64_max) | 1792 | if (std.base64.standard.Encoder.calcSize(reply.text.len) > proto.clipboard_base64_max) |
| 2208 | return .too_large; | 1793 | return .too_large; |
| 2209 | return .{ .text = reply.text }; | 1794 | return .{ .text = reply.text }; |
| 2210 | } | 1795 | } |
| 2211 | 1796 | ||
| 2212 | /// Which line of this session a report landed on, or null for a report | 1797 | /// Which line of this session a report landed on, or null. The mapping is |
| 2213 | /// that names none. | 1798 | /// trivial, which is why selection lives in the Core: a terminal cell is a |
| 2214 | /// | 1799 | /// grid cell less the tile's origin, plus the history under it. |
| 2215 | /// The mapping is trivial and that is the whole reason selection lives | ||
| 2216 | /// in the Core: a terminal cell is a grid cell less the tile's own | ||
| 2217 | /// origin, and the only conversion left is the history the daemon is | ||
| 2218 | /// holding under it. | ||
| 2219 | fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit { | 1800 | fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit { |
| 2220 | // Scrolled back, nothing on this screen is addressable: | 1801 | // Scrolled back, nothing here is addressable: `renderScrollback` blits |
| 2221 | // `renderScrollback` blits VT bytes the daemon composed, with no | 1802 | // VT bytes with no engine behind them. Refused rather than |
| 2222 | // engine behind them, so there is no row here to name. Refused | 1803 | // half-answered, or the coordinates name the live view instead. |
| 2223 | // rather than half-answered — the alternative is a selection whose | ||
| 2224 | // coordinates belong to the live view the user is not looking at. | ||
| 2225 | if (self.scroll_rows > 0) return null; | 1804 | if (self.scroll_rows > 0) return null; |
| 2226 | // A wall tile paints at `row_off` and `col_off`, so a terminal cell | 1805 | // A tile paints at `row_off`/`col_off`, so a terminal cell is a grid |
| 2227 | // is a grid cell only after the tile's origin comes off — on BOTH | 1806 | // cell only after the origin comes off — on BOTH axes. Above or left |
| 2228 | // axes. Above the tile or left of it is a neighbour's cell, not a | 1807 | // of the tile is a neighbour's cell, not a cell of this grid. |
| 2229 | // cell of this grid. The plain client and a one-tile wall leave | ||
| 2230 | // both 0 and this is the identity it always was. | ||
| 2231 | if (ev.row < self.row_off or ev.col < self.col_off) return null; | 1808 | if (ev.row < self.row_off or ev.col < self.col_off) return null; |
| 2232 | const grow = ev.row - self.row_off; | 1809 | const grow = ev.row - self.row_off; |
| 2233 | const gcol = ev.col - self.col_off; | 1810 | const gcol = ev.col - self.col_off; |
| @@ -2239,12 +1816,9 @@ pub const Core = struct { | |||
| 2239 | return .{ | 1816 | return .{ |
| 2240 | .tile = focus_tile, | 1817 | .tile = focus_tile, |
| 2241 | .row = self.rep.history_rows + grow, | 1818 | .row = self.rep.history_rows + grow, |
| 2242 | // Columns clamp instead of refusing, because the right edge is | 1819 | // Columns CLAMP rather than refuse: the right edge is where a hand |
| 2243 | // where a hand naturally overshoots. An out-of-range column is | 1820 | // overshoots, and a round trip spent being told `.invalid` is a |
| 2244 | // what makes the daemon answer `.invalid`, and a round trip | 1821 | // copy the user does not get. Clamped to what the tile SHOWS. |
| 2245 | // spent to be told so is a copy the user does not get. The | ||
| 2246 | // clamp is to what the tile SHOWS: a wider grid's remaining | ||
| 2247 | // columns are behind the rail, and no hand can reach them. | ||
| 2248 | .col = @min(gcol, @min(grid_cols, self.size.cols) -| 1), | 1822 | .col = @min(gcol, @min(grid_cols, self.size.cols) -| 1), |
| 2249 | }; | 1823 | }; |
| 2250 | } | 1824 | } |
| @@ -2259,12 +1833,10 @@ pub const Core = struct { | |||
| 2259 | self.overlay.setScrollMode(false); | 1833 | self.overlay.setScrollMode(false); |
| 2260 | } | 1834 | } |
| 2261 | 1835 | ||
| 2262 | /// A new transport is up and an attach frame has gone out on it. | 1836 | /// A new transport is up and an attach frame has gone out on it. Only the |
| 2263 | /// | 1837 | /// driver knows, so the Replica's contract makes this clear the caller's. |
| 2264 | /// Only the driver knows a re-attach happened; the Replica's contract | 1838 | /// The overlay's contents were predicted against a connection that no |
| 2265 | /// says this clear is its caller's to do. Whatever the overlay held was | 1839 | /// longer exists — dropping them is no accusation, so counters hold. |
| 2266 | /// predicted against a connection that no longer exists — dropping it | ||
| 2267 | /// is not an accusation, so the counters stay where they are. | ||
| 2268 | pub fn reattached(self: *Core) void { | 1840 | pub fn reattached(self: *Core) void { |
| 2269 | // The absolute row space is counted from the oldest row the daemon | 1841 | // The absolute row space is counted from the oldest row the daemon |
| 2270 | // retains, and a resync renames it outright — so a highlight kept | 1842 | // retains, and a resync renames it outright — so a highlight kept |
| @@ -2635,12 +2207,10 @@ test "claimTerminal: a sink that refuses leaves the claim unheld, so the caller | |||
| 2635 | var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 }); | 2207 | var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 }); |
| 2636 | defer core.deinit(); | 2208 | defer core.deinit(); |
| 2637 | core.is_tty = true; | 2209 | core.is_tty = true; |
| 2638 | // The wall's host picker refuses every tile paint while its popup owns | 2210 | // The picker refuses every tile paint while its popup owns the screen, and |
| 2639 | // the screen, and a claim writes the session's modes THROUGH that same | 2211 | // a claim writes the session's modes THROUGH that sink. False and `.none` |
| 2640 | // sink. False and `.none` together are what tell the pump to re-arm: | 2212 | // together tell the pump to re-arm; read as "already held", the tile stays |
| 2641 | // a claim read as "already held" leaves that tile focused owning no | 2213 | // focused owning no terminal until the focus moves away and back. |
| 2642 | // terminal — no mouse modes, no side channels — until the focus moves | ||
| 2643 | // away and back. | ||
| 2644 | core.sink = .{ .begin = refuseSink }; | 2214 | core.sink = .{ .begin = refuseSink }; |
| 2645 | try std.testing.expect(!core.claimTerminal()); | 2215 | try std.testing.expect(!core.claimTerminal()); |
| 2646 | try std.testing.expectEqual(Claim.none, core.claim); | 2216 | try std.testing.expectEqual(Claim.none, core.claim); |
| @@ -2974,11 +2544,9 @@ test "interact: an event knows where it fell among the keys" { | |||
| 2974 | var f: MouseFilter = .{}; | 2544 | var f: MouseFilter = .{}; |
| 2975 | var out: [64]u8 = undefined; | 2545 | var out: [64]u8 = undefined; |
| 2976 | 2546 | ||
| 2977 | // `forward` flattens the keys and drops the reports, so a driver | 2547 | // `forward` flattens the keys and drops the reports, so two flat lists |
| 2978 | // reading the two lists side by side cannot tell whether the click | 2548 | // cannot say whether the click came before or after the Enter — at the |
| 2979 | // came before or after the Enter — and at the wall that is the | 2549 | // wall, the difference between two tiles getting the focus. |
| 2980 | // difference between focusing the tile the user chose and focusing the | ||
| 2981 | // one their click had just moved the selection to. | ||
| 2982 | const key_first = f.feed("\r\x1b[<0;3;2M", &out); | 2550 | const key_first = f.feed("\r\x1b[<0;3;2M", &out); |
| 2983 | try std.testing.expectEqualStrings("\r", key_first.forward); | 2551 | try std.testing.expectEqualStrings("\r", key_first.forward); |
| 2984 | try std.testing.expectEqual(@as(usize, 1), key_first.events.len); | 2552 | try std.testing.expectEqual(@as(usize, 1), key_first.events.len); |
| @@ -2995,10 +2563,8 @@ test "interact: an event knows where it fell among the keys" { | |||
| 2995 | test "interact: a chunk packed with the shortest report fills, and does not overrun" { | 2563 | test "interact: a chunk packed with the shortest report fills, and does not overrun" { |
| 2996 | var f: MouseFilter = .{}; | 2564 | var f: MouseFilter = .{}; |
| 2997 | // The cap this pins is the filter's totality claim, so the input is the | 2565 | // The cap this pins is the filter's totality claim, so the input is the |
| 2998 | // worst case the contract admits: a whole stdin chunk of the SHORTEST | 2566 | // worst case: a whole stdin chunk of the SHORTEST complete report there |
| 2999 | // complete report there is. `\x1b[<0;1;1M` is NINE bytes — counted, not | 2567 | // is. `\x1b[<0;1;1M` is NINE bytes — counted, because ten is 181 short. |
| 3000 | // eyeballed, because the first version of this sizing said ten and came | ||
| 3001 | // out 181 events short of a full chunk. | ||
| 3002 | const shortest = "\x1b[<0;1;1M"; | 2568 | const shortest = "\x1b[<0;1;1M"; |
| 3003 | try std.testing.expectEqual(@as(usize, 9), shortest.len); | 2569 | try std.testing.expectEqual(@as(usize, 9), shortest.len); |
| 3004 | 2570 | ||
| @@ -3190,11 +2756,8 @@ test "prediction: prev_ch is read at the predicted cursor, not the replica's" { | |||
| 3190 | try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch); | 2756 | try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch); |
| 3191 | 2757 | ||
| 3192 | // A frame that changed neither cell: the daemon has not seen the | 2758 | // A frame that changed neither cell: the daemon has not seen the |
| 3193 | // keystrokes yet, so it has said nothing about them and both | 2759 | // keystrokes, so both predictions must survive it. Read `prev_ch` from the |
| 3194 | // predictions must survive it. Read prev_ch from the wrong cell and | 2760 | // wrong cell and the frame reads as a contradiction and flushes the burst. |
| 3195 | // this is where it shows — the second cell holds 'Y', which is neither | ||
| 3196 | // the prediction nor the 'X' a cursor-based read would have recorded, | ||
| 3197 | // so the frame reads as a contradiction and the burst is flushed. | ||
| 3198 | try std.testing.expectEqual( | 2761 | try std.testing.expectEqual( |
| 3199 | predict.Verdict.none, | 2762 | predict.Verdict.none, |
| 3200 | reconcileOverlay(alloc, &ov, replica, 2, 0), | 2763 | reconcileOverlay(alloc, &ov, replica, 2, 0), |
| @@ -3373,11 +2936,9 @@ test "prediction: a chunk that is not one printable byte is never speculated abo | |||
| 3373 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | 2936 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); |
| 3374 | try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed); | 2937 | try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed); |
| 3375 | 2938 | ||
| 3376 | // A paste: several printable bytes in one read. This is the shape whose | 2939 | // A paste: several printable bytes in one read. Its lead byte sails |
| 3377 | // lead byte would sail through the printability check, so the length | 2940 | // through the printability check, so the length guard is the only thing |
| 3378 | // guard is the only thing refusing it — and it is refused, because a | 2941 | // refusing it — and a paste is not one cell's worth of change. |
| 3379 | // paste can carry newlines and bracketed-paste markers that are not one | ||
| 3380 | // cell's worth of change each. | ||
| 3381 | offerKeystroke(alloc, &ov, replica, "abc", full_vp, null_fd); | 2942 | offerKeystroke(alloc, &ov, replica, "abc", full_vp, null_fd); |
| 3382 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | 2943 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); |
| 3383 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | 2944 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); |
| @@ -3405,11 +2966,9 @@ test "prediction: a repaint never reveals what was never shown" { | |||
| 3405 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | 2966 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); |
| 3406 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | 2967 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); |
| 3407 | 2968 | ||
| 3408 | // Every authoritative paint is followed by re-laying the overlay on top, | 2969 | // Every authoritative paint re-lays the overlay on top, because a delta's |
| 3409 | // because a delta's row content wipes anything drawn over it. That | 2970 | // row content wipes anything drawn over it — a second chance to show a |
| 3410 | // repaint is a second, quieter chance to show a prediction that was | 2971 | // prediction, so it asks the keystroke path's question and gets its answer. |
| 3411 | // never displayed in the first place — so it asks the same question the | ||
| 3412 | // keystroke path did, and gets the same answer. | ||
| 3413 | paintOverlay(alloc, &ov, replica.cursorPos(), full_vp, p[1]); | 2972 | paintOverlay(alloc, &ov, replica.cursorPos(), full_vp, p[1]); |
| 3414 | std.posix.close(p[1]); | 2973 | std.posix.close(p[1]); |
| 3415 | 2974 | ||
| @@ -3582,11 +3141,9 @@ test "interact: an application that asked for the mouse gets exactly the modes i | |||
| 3582 | var out: std.ArrayList(u8) = .empty; | 3141 | var out: std.ArrayList(u8) = .empty; |
| 3583 | defer out.deinit(alloc); | 3142 | defer out.deinit(alloc); |
| 3584 | 3143 | ||
| 3585 | // vim's `set mouse=a`, which asks for exactly the set the client now | 3144 | // vim's `set mouse=a` asks for exactly the set the client holds for |
| 3586 | // holds for itself — so these bytes are identical to the no-application | 3145 | // itself, so these bytes match the no-application answer above. That |
| 3587 | // answer above. That identity is the measurement this feature rests on | 3146 | // identity is what the feature rests on, not a coincidence to route around. |
| 3588 | // rather than a coincidence to route around: an editor keeps 1000+1002 | ||
| 3589 | // +1006 armed for a whole session and nobody calls that wasteful. | ||
| 3590 | try appendMouseModes(&out, alloc, .{ | 3147 | try appendMouseModes(&out, alloc, .{ |
| 3591 | .bracketed_paste = false, | 3148 | .bracketed_paste = false, |
| 3592 | .mouse_normal = true, | 3149 | .mouse_normal = true, |
| @@ -3612,12 +3169,9 @@ test "interact: an application that asked for the mouse gets exactly the modes i | |||
| 3612 | }); | 3169 | }); |
| 3613 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1003h") != null); | 3170 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1003h") != null); |
| 3614 | 3171 | ||
| 3615 | // An application on the legacy format gets the legacy format, and gets | 3172 | // An application on the legacy format gets it by SUBTRACTION: our own |
| 3616 | // it by SUBTRACTION: leaving our own 1006 on would spell every click in | 3173 | // 1006 would spell every click in a shape it cannot parse, and 1002 would |
| 3617 | // a shape it cannot parse, and leaving 1002 on would send it drags it | 3174 | // send drags it never asked for. The leg proving the override removes. |
| 3618 | // never asked for. Both are modes the client holds when nobody is | ||
| 3619 | // asking, so this is the leg that proves the override takes away as | ||
| 3620 | // well as adds. | ||
| 3621 | out.clearRetainingCapacity(); | 3175 | out.clearRetainingCapacity(); |
| 3622 | try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_normal = true }); | 3176 | try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_normal = true }); |
| 3623 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1006l") != null); | 3177 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1006l") != null); |
| @@ -3694,12 +3248,9 @@ test "interact: the exit teardown unsets every mode mux turned on, and pops the | |||
| 3694 | "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l", | 3248 | "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l", |
| 3695 | terminal_teardown, | 3249 | terminal_teardown, |
| 3696 | ); | 3250 | ); |
| 3697 | // The pop is worthless — worse, it pops a stranger's title — without | 3251 | // Without its push, the pop is worthless and pops a stranger's title. The |
| 3698 | // the push that pairs with it, and the two live far apart: the push | 3252 | // two live far apart — one startup write against a teardown on every way |
| 3699 | // rides in on the wall's screen entry, one write at startup for a | 3253 | // out — so they are pinned together and deleting either one fails. |
| 3700 | // teardown written on every way out. Pinned here together so deleting | ||
| 3701 | // either one fails, rather than quietly leaving the terminal one push | ||
| 3702 | // deep forever or one pop too many. | ||
| 3703 | try std.testing.expectEqualStrings( | 3254 | try std.testing.expectEqualStrings( |
| 3704 | "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ | 3255 | "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ |
| 3705 | "\x1b[H\x1b[2J", | 3256 | "\x1b[H\x1b[2J", |
| @@ -3718,14 +3269,10 @@ test "interact: the exit teardown unsets every mode mux turned on, and pops the | |||
| 3718 | } | 3269 | } |
| 3719 | 3270 | ||
| 3720 | test "interact: a borrowed terminal's claim is a session's, and every teardown undoes it" { | 3271 | test "interact: a borrowed terminal's claim is a session's, and every teardown undoes it" { |
| 3721 | // The claim a wall tile takes when it gains focus is the mouse modes and | 3272 | // A tile's focus claim is the mouse modes and nothing else — the SCREEN |
| 3722 | // nothing else — the SCREEN was taken once, by the wall — and the | 3273 | // was taken once, by the wall — and the release heads every teardown. This |
| 3723 | // release is the head of every teardown there is. | 3274 | // pairing turns over once per focus move where the screen's turns over |
| 3724 | // | 3275 | // once per process, so an exit-path-only half leaks on the first move. |
| 3725 | // The pairing this pins turns over many times per run — once per focus move, | ||
| 3726 | // where the screen's turns over once per process — so a half that only | ||
| 3727 | // works on the exit path is a wall that leaves a terminal reporting | ||
| 3728 | // clicks into a shell the moment focus moves on. | ||
| 3729 | try std.testing.expectEqualStrings("\x1b[?1000h\x1b[?1002h\x1b[?1006h", session_claim); | 3276 | try std.testing.expectEqualStrings("\x1b[?1000h\x1b[?1002h\x1b[?1006h", session_claim); |
| 3730 | try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release)); | 3277 | try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release)); |
| 3731 | try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null); | 3278 | try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null); |
| @@ -3819,11 +3366,9 @@ test "interact: a promote takes the mouse, a demote gives it back, a demote twic | |||
| 3819 | // modes level-set on top of it. | 3366 | // modes level-set on top of it. |
| 3820 | const claimed = drainPipe(p[0], &buf); | 3367 | const claimed = drainPipe(p[0], &buf); |
| 3821 | try std.testing.expect(std.mem.startsWith(u8, claimed, session_claim)); | 3368 | try std.testing.expect(std.mem.startsWith(u8, claimed, session_claim)); |
| 3822 | // A mode the claim never names, so its presence can only be the | 3369 | // A mode the claim never names, so its presence can only be the level-set |
| 3823 | // level-set — the half a claim needs because `resyncSnapshot` answers | 3370 | // — the half a claim needs because `resyncSnapshot` carries no |
| 3824 | // a resize with no `term_modes` at all. 1003 rather than 1002: 1002 is | 3371 | // `term_modes`. 1003 not 1002: the claim writes 1002 itself. |
| 3825 | // in the client's own capture set now, so the claim writes it and its | ||
| 3826 | // presence would no longer distinguish the two writes. | ||
| 3827 | try std.testing.expect(std.mem.indexOf(u8, claimed, "\x1b[?1003l") != null); | 3372 | try std.testing.expect(std.mem.indexOf(u8, claimed, "\x1b[?1003l") != null); |
| 3828 | 3373 | ||
| 3829 | // A claim onto the tile already focused re-asserts the grid, not the | 3374 | // A claim onto the tile already focused re-asserts the grid, not the |
| @@ -3967,11 +3512,9 @@ fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core { | |||
| 3967 | // exactly the focused-tile case, and the reason a non-focused tile | 3512 | // exactly the focused-tile case, and the reason a non-focused tile |
| 3968 | // never reaches any of this. | 3513 | // never reaches any of this. |
| 3969 | _ = core.claimTerminal(); | 3514 | _ = core.claimTerminal(); |
| 3970 | // Row 4 carries wide cells. Kept OFF the rows the column assertions | 3515 | // Row 4 carries wide cells, kept OFF the rows the column assertions use: |
| 3971 | // above use, rather than folded into them: a wide glyph shifts every | 3516 | // a wide glyph shifts every column right of it, so folding one into row 1 |
| 3972 | // column to its right, so putting one in row 1 would have meant | 3517 | // means re-deriving fifteen hand-checked numbers. |
| 3973 | // re-deriving fifteen hand-checked column numbers — churn that hides | ||
| 3974 | // regressions instead of catching them. | ||
| 3975 | core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three\r\nw\u{6f22}\u{5b57}x"); | 3518 | core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three\r\nw\u{6f22}\u{5b57}x"); |
| 3976 | return core; | 3519 | return core; |
| 3977 | } | 3520 | } |
| @@ -4272,11 +3815,9 @@ test "interact: the anchor is an absolute row, and the paint converts it back" { | |||
| 4272 | var buf: [8192]u8 = undefined; | 3815 | var buf: [8192]u8 = undefined; |
| 4273 | _ = drainPipe(p[0], &buf); | 3816 | _ = drainPipe(p[0], &buf); |
| 4274 | 3817 | ||
| 4275 | // A session with history behind it, which is every session that has | 3818 | // A session with history behind it, which is every used session. With |
| 4276 | // been used. The number is what makes the two directions of the | 3819 | // none retained a grid row and an absolute row are the same integer, and |
| 4277 | // conversion distinguishable at all: with none retained, a grid row | 3820 | // neither half of the conversion is under test. |
| 4278 | // and an absolute row are the same integer and neither half of the | ||
| 4279 | // arithmetic is under test. | ||
| 4280 | core.rep.history_rows = 500; | 3821 | core.rep.history_rows = 500; |
| 4281 | 3822 | ||
| 4282 | try mouse(&core, &tr, 0, 3, 2, 'M'); | 3823 | try mouse(&core, &tr, 0, 3, 2, 'M'); |
| @@ -4420,11 +3961,9 @@ test "interact: a drag edge inside a wide cell repaints the row unshifted" { | |||
| 4420 | // (0-based 1, where 漢 starts), not at the column the pointer stopped in. | 3961 | // (0-based 1, where 漢 starts), not at the column the pointer stopped in. |
| 4421 | try std.testing.expect(std.mem.indexOf(u8, painted, comptime cha(2) ++ "\x1b[0m\x1b[7m") != null); | 3962 | try std.testing.expect(std.mem.indexOf(u8, painted, comptime cha(2) ++ "\x1b[0m\x1b[7m") != null); |
| 4422 | 3963 | ||
| 4423 | // The oracle, and the reason this test exists at all: replay what the | 3964 | // The oracle: replay what the client actually WROTE through a fresh engine |
| 4424 | // client actually WROTE through a fresh engine and read the row back. | 3965 | // and read the row back. Asserting escape bytes cannot see this class of |
| 4425 | // Asserting escape bytes cannot see this class of bug — a row that | 3966 | // bug — a row emitting 漢 twice still contains every expected substring. |
| 4426 | // emits 漢 twice still contains every substring the painter is supposed | ||
| 4427 | // to emit, and lands a column wider than the grid. | ||
| 4428 | var screen = try Engine.init(alloc, .{ .cols = 20, .rows = 6 }); | 3967 | var screen = try Engine.init(alloc, .{ .cols = 20, .rows = 6 }); |
| 4429 | defer screen.deinit(); | 3968 | defer screen.deinit(); |
| 4430 | screen.feed(painted); | 3969 | screen.feed(painted); |
| @@ -4468,12 +4007,9 @@ test "interact: a press left of the pane names no line, and the clamp is the pan | |||
| 4468 | var buf: [8192]u8 = undefined; | 4007 | var buf: [8192]u8 = undefined; |
| 4469 | _ = drainPipe(p[0], &buf); | 4008 | _ = drainPipe(p[0], &buf); |
| 4470 | 4009 | ||
| 4471 | // Screen column 1, which is a cell of the neighbour across the rail. | 4010 | // Screen column 1 is a cell of the neighbour across the rail: the column |
| 4472 | // The row axis refuses above the tile; the column axis refuses left of | 4011 | // axis refuses left of the tile as the row axis refuses above it. The row |
| 4473 | // it for the same reason — that cell is not a cell of this grid, and | 4012 | // here is inside the tile's band, so only the column can refuse. |
| 4474 | // naming it as one is how every drag in a right-hand pane came back | ||
| 4475 | // `col_off` columns adrift. | ||
| 4476 | // The row is inside the tile's band, so only the column can refuse it. | ||
| 4477 | _ = try core.forward(&tr, comptime std.fmt.comptimePrint("\x1b[<0;1;{d}M", .{drag_row_off + 2})); | 4013 | _ = try core.forward(&tr, comptime std.fmt.comptimePrint("\x1b[<0;1;{d}M", .{drag_row_off + 2})); |
| 4478 | try std.testing.expect(core.drag.range() == null); | 4014 | try std.testing.expect(core.drag.range() == null); |
| 4479 | 4015 | ||
| @@ -4706,11 +4242,9 @@ test "interact: a selection too big for OSC 52 is refused out loud, never trimme | |||
| 4706 | var buf: [8192]u8 = undefined; | 4242 | var buf: [8192]u8 = undefined; |
| 4707 | _ = drainPipe(p[0], &buf); | 4243 | _ = drainPipe(p[0], &buf); |
| 4708 | 4244 | ||
| 4709 | // Base64 is 4 bytes per 3, so the 64 KiB cap on the encoded form | 4245 | // Base64 is 4 bytes per 3, so `protocol.clipboard_base64_max` is reached |
| 4710 | // (`protocol.clipboard_base64_max`) is reached by three quarters of it | 4246 | // by three quarters of it in text — far under the 1 MiB the daemon will |
| 4711 | // in text — a long way under the 1 MiB the daemon will happily send | 4247 | // send, which is the gap this exists to fall into. |
| 4712 | // (`protocol.selection_text_max`), which is the whole reason this gap | ||
| 4713 | // exists to fall into. | ||
| 4714 | const fits = proto.clipboard_base64_max / 4 * 3; | 4248 | const fits = proto.clipboard_base64_max / 4 * 3; |
| 4715 | const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + fits + 1); | 4249 | const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + fits + 1); |
| 4716 | defer alloc.free(rbuf); | 4250 | defer alloc.free(rbuf); |
| @@ -4812,11 +4346,9 @@ test "interact: a frame the Core answers itself never reaches the driver" { | |||
| 4812 | // A page of history for a view that is already live would be painted | 4346 | // A page of history for a view that is already live would be painted |
| 4813 | // over a screen it no longer describes. | 4347 | // over a screen it no longer describes. |
| 4814 | try std.testing.expectEqual(Routed.skip, try core.frame(.scrollback_chunk, "\x00\x00\x00\x00\x01\x00")); | 4348 | try std.testing.expectEqual(Routed.skip, try core.frame(.scrollback_chunk, "\x00\x00\x00\x00\x01\x00")); |
| 4815 | // A delta the replica could not compose is a resync — and it is STATE | 4349 | // A delta the replica could not compose is a resync — and STATE to its |
| 4816 | // to its driver anyway, because `apply` marks the attach as landed | 4350 | // driver anyway, because `apply` marks the attach landed before it can |
| 4817 | // before it can refuse. Both drivers write their attach history off | 4351 | // refuse. A shell dying in the same read as its first delta must not lose it. |
| 4818 | // that, and a shell that dies in the same read as its first delta must | ||
| 4819 | // not lose it. | ||
| 4820 | try std.testing.expectEqual(Routed.resync, try core.frame(.delta, "")); | 4352 | try std.testing.expectEqual(Routed.resync, try core.frame(.delta, "")); |
| 4821 | try std.testing.expect(core.rep.state_since_attach); | 4353 | try std.testing.expect(core.rep.state_since_attach); |
| 4822 | } | 4354 | } |