c2a60319
feat: a click at the wall selects the stripe under it
a73x 2026-08-22 13:13
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -69,6 +69,7 @@ Zoomed out — the wall reads the keys and nothing reaches a session: | |||
| 69 | | Key | Effect | | 69 | | Key | Effect | |
| 70 | |---|---| | 70 | |---|---| |
| 71 | | `j` / `k` (or `n` / `p`), `1`-`9` | select a tile | | 71 | | `j` / `k` (or `n` / `p`), `1`-`9` | select a tile | |
| 72 | | click a stripe | select that tile | | ||
| 72 | | `Enter` | zoom the selected tile | | 73 | | `Enter` | zoom the selected tile | |
| 73 | | `x` | forget the selected tile (off the wall file; the session keeps running) | | 74 | | `x` | forget the selected tile (off the wall file; the session keeps running) | |
| 74 | | `q` (or `Ctrl-\`) | leave mux | | 75 | | `q` (or `Ctrl-\`) | leave mux | |
| @@ -311,7 +312,8 @@ tile mid-paint — so a wall you never zoom keeps its old widths. | |||
| 311 | The keys are the ones listed under "Quick start, local" — `mux wall` and | 312 | The keys are the ones listed under "Quick start, local" — `mux wall` and |
| 312 | `mux` are the same program, entered through different doors, so there is | 313 | `mux` are the same program, entered through different doors, so there is |
| 313 | one key table and not two. Zoomed out, `j`/`k` (or `n`/`p`) and `1`-`9` | 314 | one key table and not two. Zoomed out, `j`/`k` (or `n`/`p`) and `1`-`9` |
| 314 | select, `Enter` zooms, `x` forgets, `q` leaves. Zoomed in, the session has | 315 | select — as does clicking a stripe — `Enter` zooms, `x` forgets, `q` |
| 316 | leaves. Zoomed in, the session has | ||
| 315 | the terminal and `Ctrl-\` is the only thing held back: `w` zooms out, `d` | 317 | the terminal and `Ctrl-\` is the only thing held back: `w` zooms out, `d` |
| 316 | detaches and leaves, `n`/`p`/`c` walk the daemon's session ring, `l` skips | 318 | detaches and leaves, `n`/`p`/`c` walk the daemon's session ring, `l` skips |
| 317 | back to the last tile you zoomed. Any other command key after the prefix is | 319 | back to the last tile you zoomed. Any other command key after the prefix is |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -7,8 +7,9 @@ | |||
| 7 | //! the terminal's size, which is all of `sendAttach`. On the wall, `q` or | 7 | //! the terminal's size, which is all of `sendAttach`. On the wall, `q` or |
| 8 | //! Ctrl-\ leaves. | 8 | //! Ctrl-\ leaves. |
| 9 | //! | 9 | //! |
| 10 | //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump); | 10 | //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump, or |
| 11 | //! its label bar carries a `> ` marker. `Enter` ZOOMS it, in place. | 11 | //! click one); its label bar carries a `> ` marker. `Enter` ZOOMS it, in |
| 12 | //! place. | ||
| 12 | //! | 13 | //! |
| 13 | //! `x` FORGETS the selected tile: its line leaves the wall file, its pump | 14 | //! `x` FORGETS the selected tile: its line leaves the wall file, its pump |
| 14 | //! ends (closing the transport, freeing the daemon slot), and the stripes | 15 | //! ends (closing the transport, freeing the daemon slot), and the stripes |
| @@ -315,6 +316,19 @@ const Shared = struct { | |||
| 315 | repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), | 316 | repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), |
| 316 | }; | 317 | }; |
| 317 | 318 | ||
| 319 | /// The stripe window a paint used, in the only terms a click can be | ||
| 320 | /// resolved in. | ||
| 321 | /// | ||
| 322 | /// Absolute rows are counted from the oldest row the DAEMON still retains | ||
| 323 | /// (`protocol.SelectionReply`), so a grid row alone names nothing: it has | ||
| 324 | /// to be read against the history count the same frame carried. | ||
| 325 | const Window = struct { | ||
| 326 | history_rows: u32 = 0, | ||
| 327 | /// The grid row the window's first content line came from | ||
| 328 | /// (`paint.stripeWinStart`). | ||
| 329 | win_start: u16 = 0, | ||
| 330 | }; | ||
| 331 | |||
| 318 | const Tile = struct { | 332 | const Tile = struct { |
| 319 | r: Resolved, | 333 | r: Resolved, |
| 320 | stripe: Stripe, | 334 | stripe: Stripe, |
| @@ -439,11 +453,201 @@ const Tile = struct { | |||
| 439 | /// keystrokes never vanish without the wall admitting to it. | 453 | /// keystrokes never vanish without the wall admitting to it. |
| 440 | in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), | 454 | in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), |
| 441 | 455 | ||
| 456 | /// What this tile's last stripe paint showed. | ||
| 457 | /// | ||
| 458 | /// Written by the PUMP under `paint_mu` inside the paint that used it, | ||
| 459 | /// read by the KEYBOARD under the same lock when a click has to be | ||
| 460 | /// turned into a line. One lock over both fields is the point: a | ||
| 461 | /// window from one frame read against a history count from another | ||
| 462 | /// names a row nobody ever saw. | ||
| 463 | /// | ||
| 464 | /// Anchors are converted at PRESS time for the same reason — a | ||
| 465 | /// terminal row remembered instead would quietly become a different | ||
| 466 | /// line as soon as the session scrolled the window under it. | ||
| 467 | win: Window = .{}, | ||
| 468 | |||
| 442 | fn viewRows(t: *const Tile) u16 { | 469 | fn viewRows(t: *const Tile) u16 { |
| 443 | return t.stripe.rows - 1; | 470 | return t.stripe.rows - 1; |
| 444 | } | 471 | } |
| 445 | }; | 472 | }; |
| 446 | 473 | ||
| 474 | /// How far down a stripe's CONTENT a zero-based terminal row lands, or null | ||
| 475 | /// when that row names no session line at all. | ||
| 476 | /// | ||
| 477 | /// Two rows are null on purpose: the label bar (`Stripe.top` itself, since | ||
| 478 | /// `Stripe` counts from zero and the bar is its first row) and anything | ||
| 479 | /// outside the stripe. A click there points at nothing, and answering with | ||
| 480 | /// the nearest line would move a selection the user did not ask to move — | ||
| 481 | /// the bar is exactly where a hand rests between two stripes. | ||
| 482 | fn stripeContentRow(s: Stripe, row: u16) ?u16 { | ||
| 483 | if (row <= s.top) return null; | ||
| 484 | const off = row - s.top - 1; | ||
| 485 | return if (off < s.rows -| 1) off else null; | ||
| 486 | } | ||
| 487 | |||
| 488 | /// One place in one session, as a click names it. | ||
| 489 | const Hit = struct { | ||
| 490 | tile: usize, | ||
| 491 | /// Absolute row, counted from the oldest row the daemon still retains | ||
| 492 | /// — the coordinate space `protocol.SelectionReq` speaks. | ||
| 493 | row: u32, | ||
| 494 | /// Terminal column, zero-based. Grid columns are the same number: | ||
| 495 | /// every painter emits from column 1 with no x-offset, and DECAWM-off | ||
| 496 | /// clips at the right edge. | ||
| 497 | col: u16, | ||
| 498 | }; | ||
| 499 | |||
| 500 | /// Which session line a zero-based terminal (row, col) points at. | ||
| 501 | /// | ||
| 502 | /// Under `paint_mu` because `Tile.stripe` and `Tile.win` are both written | ||
| 503 | /// under it: a re-layout landing between reading one and the other would | ||
| 504 | /// resolve the click against a geometry that never existed. | ||
| 505 | /// | ||
| 506 | /// `present` and not the geometry decides whether a tile is there at all — | ||
| 507 | /// `relayout` never compacts the tile array, so a forgotten tile keeps the | ||
| 508 | /// stripe it had when it left. | ||
| 509 | fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?Hit { | ||
| 510 | shared.paint_mu.lock(); | ||
| 511 | defer shared.paint_mu.unlock(); | ||
| 512 | for (tiles, present) |*t, p| { | ||
| 513 | if (!p) continue; | ||
| 514 | const off = stripeContentRow(t.stripe, row) orelse continue; | ||
| 515 | return .{ | ||
| 516 | .tile = t.idx, | ||
| 517 | .row = t.win.history_rows + t.win.win_start + off, | ||
| 518 | .col = col, | ||
| 519 | }; | ||
| 520 | } | ||
| 521 | return null; | ||
| 522 | } | ||
| 523 | |||
| 524 | /// One read's keys and mouse reports, handed back in the order the terminal | ||
| 525 | /// wrote them. | ||
| 526 | /// | ||
| 527 | /// `MouseFilter.feed` returns two flat lists, and `Event.at` — how many | ||
| 528 | /// forwarded bytes preceded a report — is the only record left of how they | ||
| 529 | /// interleaved. Draining all of one list and then the other is wrong in | ||
| 530 | /// both directions, and the read that proves it is one holding a Return and | ||
| 531 | /// a click: Enter zooms the SELECTED tile, a press moves the selection, so | ||
| 532 | /// events-first zooms the tile the click had just selected rather than the | ||
| 533 | /// one the user chose. | ||
| 534 | const WallStep = union(enum) { key: u8, mouse: interact.MouseFilter.Event }; | ||
| 535 | |||
| 536 | const WallDrain = struct { | ||
| 537 | forward: []const u8, | ||
| 538 | events: []const interact.MouseFilter.Event, | ||
| 539 | ki: usize = 0, | ||
| 540 | ei: usize = 0, | ||
| 541 | |||
| 542 | fn init(out: interact.MouseFilter.Out) WallDrain { | ||
| 543 | return .{ .forward = out.forward, .events = out.events }; | ||
| 544 | } | ||
| 545 | |||
| 546 | fn next(self: *WallDrain) ?WallStep { | ||
| 547 | // `at` counts the bytes emitted BEFORE the report finished, so a | ||
| 548 | // report tied with a key arrived ahead of it. The same test drains | ||
| 549 | // the tail: a report that ended the read carries `at == forward.len`. | ||
| 550 | if (self.ei < self.events.len and self.events[self.ei].at <= self.ki) { | ||
| 551 | defer self.ei += 1; | ||
| 552 | return .{ .mouse = self.events[self.ei] }; | ||
| 553 | } | ||
| 554 | if (self.ki < self.forward.len) { | ||
| 555 | defer self.ki += 1; | ||
| 556 | return .{ .key = self.forward[self.ki] }; | ||
| 557 | } | ||
| 558 | return null; | ||
| 559 | } | ||
| 560 | }; | ||
| 561 | |||
| 562 | /// What the wall is holding between a press and its release. | ||
| 563 | const Click = struct { | ||
| 564 | /// Where the press landed, or null when this wall knows of no button | ||
| 565 | /// down. Resolved at PRESS time and kept resolved: a terminal row | ||
| 566 | /// remembered instead would name a different line as soon as the | ||
| 567 | /// session scrolled the window under it. | ||
| 568 | down: ?Hit = null, | ||
| 569 | /// The terminal cell the press was on. | ||
| 570 | row: u16 = 0, | ||
| 571 | col: u16 = 0, | ||
| 572 | /// Whether the pointer left that cell before the button came up. | ||
| 573 | dragged: bool = false, | ||
| 574 | }; | ||
| 575 | |||
| 576 | /// What a mouse report does at an UNZOOMED wall. | ||
| 577 | /// | ||
| 578 | /// A plain click — press and release with the pointer never leaving the | ||
| 579 | /// cell — moves the selection to the stripe under it. That is tmux's | ||
| 580 | /// `MouseDown1Pane -> select-pane`, deliberately, because it is what the | ||
| 581 | /// user already has in their hands. A drag does nothing yet. | ||
| 582 | /// | ||
| 583 | /// The selection follows the RELEASE and not the press, so a press that | ||
| 584 | /// turns out to be the start of a drag never flickers the marker on its | ||
| 585 | /// way past. | ||
| 586 | /// | ||
| 587 | /// Left button only. Middle is the terminal's own paste and right its menu; | ||
| 588 | /// stealing either would be a surprise the wall has no answer for. The | ||
| 589 | /// button word arrives from the filter verbatim, modifier bits and the | ||
| 590 | /// motion bit included, so it is the low two bits that name the button. | ||
| 591 | fn wallMouse( | ||
| 592 | tiles: []Tile, | ||
| 593 | present: []const bool, | ||
| 594 | shared: *Shared, | ||
| 595 | click: *Click, | ||
| 596 | ev: interact.MouseFilter.Event, | ||
| 597 | ) void { | ||
| 598 | if (ev.button & 0b11 != 0) return; | ||
| 599 | switch (ev.kind) { | ||
| 600 | .press => click.* = .{ | ||
| 601 | .down = hitTest(tiles, present, shared, ev.row, ev.col), | ||
| 602 | .row = ev.row, | ||
| 603 | .col = ev.col, | ||
| 604 | }, | ||
| 605 | // Cell granularity, not pixel: `?1002h` reports motion when the | ||
| 606 | // pointer changes CELL, and a hand that trembles inside one cell | ||
| 607 | // is still pointing at one line. | ||
| 608 | .motion => if (ev.row != click.row or ev.col != click.col) { | ||
| 609 | click.dragged = true; | ||
| 610 | }, | ||
| 611 | .release => { | ||
| 612 | const was = click.*; | ||
| 613 | click.* = .{}; | ||
| 614 | if (was.dragged) return; | ||
| 615 | const hit = was.down orelse return; | ||
| 616 | // The press may have landed on a tile `x` has taken away | ||
| 617 | // since — the same guard `Enter` keeps for the same reason. | ||
| 618 | if (hit.tile < present.len and present[hit.tile]) | ||
| 619 | moveSelection(tiles, shared, hit.tile); | ||
| 620 | }, | ||
| 621 | } | ||
| 622 | } | ||
| 623 | |||
| 624 | /// Everything the wall's keyboard loop is holding mid-stream. | ||
| 625 | /// | ||
| 626 | /// One struct because they are dropped at one instant: `setZoom` hands the | ||
| 627 | /// terminal to somebody else, and a chord half typed, a report half read or | ||
| 628 | /// a button still down at the wall is all state about a screen that has | ||
| 629 | /// stopped existing. The reset is `setZoom`'s own (`WallInput.reset`) | ||
| 630 | /// rather than a line beside one of its six call sites — it belongs to the | ||
| 631 | /// transition, and the version that sat at a single call site held only | ||
| 632 | /// because the other five are reachable from an already-zoomed wall. | ||
| 633 | const WallInput = struct { | ||
| 634 | prefix: interact.PrefixFilter = .{}, | ||
| 635 | /// The wall's own mouse filter — the second in the process, and not an | ||
| 636 | /// alternative to the first: a promoted tile's `Core` runs one over the | ||
| 637 | /// bytes it is handed zoomed, this one runs over the bytes that arrive | ||
| 638 | /// UNZOOMED, which the wall's own modes are what produce. | ||
| 639 | mouse: interact.MouseFilter = .{}, | ||
| 640 | click: Click = .{}, | ||
| 641 | |||
| 642 | fn reset(self: *WallInput) void { | ||
| 643 | self.prefix = .{}; | ||
| 644 | // Whatever the filter still holds is the head of a report from the | ||
| 645 | // read being dropped here. | ||
| 646 | self.mouse.reset(); | ||
| 647 | self.click = .{}; | ||
| 648 | } | ||
| 649 | }; | ||
| 650 | |||
| 447 | /// The next tile in `present` after `sel`, wrapping, or null when none is | 651 | /// The next tile in `present` after `sel`, wrapping, or null when none is |
| 448 | /// left. `sel` itself is the answer when it is the only one present, which | 652 | /// left. `sel` itself is the answer when it is the only one present, which |
| 449 | /// is what makes a one-tile wall's `j` a no-op rather than a null. | 653 | /// is what makes a one-tile wall's `j` a no-op rather than a null. |
| @@ -617,14 +821,19 @@ fn moveSelection(tiles: []Tile, shared: *Shared, next: usize) void { | |||
| 617 | /// of it, and while it is DEMOTED the Core paints nothing at all and this | 821 | /// of it, and while it is DEMOTED the Core paints nothing at all and this |
| 618 | /// draws the wall's own crop of the same replica. One replica per tile, one | 822 | /// draws the wall's own crop of the same replica. One replica per tile, one |
| 619 | /// applier for it, two ways of looking at it. | 823 | /// applier for it, two ways of looking at it. |
| 620 | fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine) bool { | 824 | fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u32) bool { |
| 621 | t.shared.paint_mu.lock(); | 825 | t.shared.paint_mu.lock(); |
| 622 | defer t.shared.paint_mu.unlock(); | 826 | defer t.shared.paint_mu.unlock(); |
| 623 | if (paintModeLocked(t) != .stripe) return false; | 827 | if (paintModeLocked(t) != .stripe) return false; |
| 624 | paint.renderStripe(alloc, eng, t.stripe.top + 1, .{ | 828 | const view: proto.Size = .{ .cols = t.shared.size.cols, .rows = t.viewRows() }; |
| 625 | .cols = t.shared.size.cols, | 829 | // Published from inside the paint, under the lock the paint already |
| 626 | .rows = t.viewRows(), | 830 | // holds, because the keyboard's hit-test has to describe the rows that |
| 627 | }, t.shared.out_fd) catch {}; | 831 | // are ON the terminal — not the ones the next frame will put there. |
| 832 | t.win = .{ | ||
| 833 | .history_rows = history_rows, | ||
| 834 | .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), | ||
| 835 | }; | ||
| 836 | paint.renderStripe(alloc, eng, t.stripe.top + 1, view, t.shared.out_fd) catch {}; | ||
| 628 | return true; | 837 | return true; |
| 629 | } | 838 | } |
| 630 | 839 | ||
| @@ -799,10 +1008,20 @@ fn endWith(t: *Tile, reason: EndReason, code: u8) void { | |||
| 799 | /// only on the zoomed branch of that loop, so a report that gets here | 1008 | /// only on the zoomed branch of that loop, so a report that gets here |
| 800 | /// belongs to a session by construction. | 1009 | /// belongs to a session by construction. |
| 801 | /// | 1010 | /// |
| 802 | /// A report in flight across the boundary lands on whichever side the zoom | 1011 | /// A WHOLE report in flight across the boundary lands on whichever side the |
| 803 | /// was on when the read returned: the wall's filter eats it, or the | 1012 | /// zoom was on when the read returned: the wall's filter eats it, or the |
| 804 | /// session's does. Neither side is ever handed the other's, which is what | 1013 | /// session's does. That is what keeps a wall drag from becoming tile input. |
| 805 | /// keeps a wall drag from becoming tile input. | 1014 | /// |
| 1015 | /// A report CUT by a read boundary is the one corner that is not covered, | ||
| 1016 | /// and it is the same split class `interact.MouseFilter` admits to at its | ||
| 1017 | /// own edge. The wall holds the head; `setZoom` drops it (`WallInput.reset` | ||
| 1018 | /// — the bytes describe a screen that has changed hands); the next read | ||
| 1019 | /// arrives zoomed and its tail comes here as keystrokes, so a shell can be | ||
| 1020 | /// handed `0;3;2M`. It needs a terminal that splits one report across two | ||
| 1021 | /// writes AND a zoom landing in the gap, which is why it is named rather | ||
| 1022 | /// than defended against: the alternative is holding a partial report | ||
| 1023 | /// across the transition, which is state about a screen nobody is looking | ||
| 1024 | /// at any more. | ||
| 806 | /// | 1025 | /// |
| 807 | /// A chunk that does not fit is dropped WHOLE. The obvious alternative — | 1026 | /// A chunk that does not fit is dropped WHOLE. The obvious alternative — |
| 808 | /// copy what fits — splices: the head of one read lands in the mailbox, the | 1027 | /// copy what fits — splices: the head of one read lands in the mailbox, the |
| @@ -1376,7 +1595,7 @@ fn pumpTile(t: *Tile) void { | |||
| 1376 | // The Core has already painted the terminal if this | 1595 | // The Core has already painted the terminal if this |
| 1377 | // tile owns it; this is the wall's own crop of the | 1596 | // tile owns it; this is the wall's own crop of the |
| 1378 | // same replica, and it draws only when it doesn't. | 1597 | // same replica, and it draws only when it doesn't. |
| 1379 | _ = paintStripe(t, alloc, core.grid()); | 1598 | _ = paintStripe(t, alloc, core.grid(), core.rep.history_rows); |
| 1380 | }, | 1599 | }, |
| 1381 | // The replica is suspect, not the transport: re-attach | 1600 | // The replica is suspect, not the transport: re-attach |
| 1382 | // quoting (0,0) explicitly — a quoted seq would invite | 1601 | // quoting (0,0) explicitly — a quoted seq would invite |
| @@ -1641,7 +1860,7 @@ fn pumpTile(t: *Tile) void { | |||
| 1641 | // stripe, and records only if the stripe landed — a tile hidden | 1860 | // stripe, and records only if the stripe landed — a tile hidden |
| 1642 | // behind another tile's zoom has not satisfied anything and | 1861 | // behind another tile's zoom has not satisfied anything and |
| 1643 | // must repaint when the terminal comes back to the wall. | 1862 | // must repaint when the terminal comes back to the wall. |
| 1644 | if (promoted or paintStripe(t, alloc, core.grid())) painted_gen = gen; | 1863 | if (promoted or paintStripe(t, alloc, core.grid(), core.rep.history_rows)) painted_gen = gen; |
| 1645 | } | 1864 | } |
| 1646 | } | 1865 | } |
| 1647 | } | 1866 | } |
| @@ -1768,7 +1987,12 @@ pub fn zoomChord( | |||
| 1768 | /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never | 1987 | /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never |
| 1769 | /// comes back to the wall, and zooming a dead tile paints an entirely blank | 1988 | /// comes back to the wall, and zooming a dead tile paints an entirely blank |
| 1770 | /// terminal that admits to nothing. See `Tile.alive`. | 1989 | /// terminal that admits to nothing. See `Tile.alive`. |
| 1771 | fn setZoom(tiles: []Tile, shared: *Shared, next: usize) void { | 1990 | fn setZoom(tiles: []Tile, shared: *Shared, input: *WallInput, next: usize) void { |
| 1991 | // The terminal is changing hands, so whatever the wall's own filters | ||
| 1992 | // are mid-way through belongs to a screen that is about to stop | ||
| 1993 | // existing. Here rather than at the call sites: this is the transition, | ||
| 1994 | // and there are six of them. | ||
| 1995 | input.reset(); | ||
| 1772 | shared.paint_mu.lock(); | 1996 | shared.paint_mu.lock(); |
| 1773 | defer shared.paint_mu.unlock(); | 1997 | defer shared.paint_mu.unlock(); |
| 1774 | // See the note above: the outgoing session's modes come off HERE, on | 1998 | // See the note above: the outgoing session's modes come off HERE, on |
| @@ -2606,12 +2830,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2606 | // reaches a session — that is what makes an unzoomed tile claim | 2830 | // reaches a session — that is what makes an unzoomed tile claim |
| 2607 | // nothing); zoomed IN the terminal belongs to the session and only the | 2831 | // nothing); zoomed IN the terminal belongs to the session and only the |
| 2608 | // `Ctrl-\` chord layer is held back. | 2832 | // `Ctrl-\` chord layer is held back. |
| 2609 | var prefix: interact.PrefixFilter = .{}; | 2833 | var input: WallInput = .{}; |
| 2610 | // The wall's own mouse filter — the second in the process, and not an | ||
| 2611 | // alternative to the first: a promoted tile's `Core` runs one over the | ||
| 2612 | // bytes it is handed zoomed, this one runs over the bytes that arrive | ||
| 2613 | // UNZOOMED, which the wall's own modes are what produce. | ||
| 2614 | var mouse: interact.MouseFilter = .{}; | ||
| 2615 | // `feed` hands a candidate held across the previous read back ahead of | 2834 | // `feed` hands a candidate held across the previous read back ahead of |
| 2616 | // this chunk, so its room is a whole chunk plus that hold. | 2835 | // this chunk, so its room is a whole chunk plus that hold. |
| 2617 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; | 2836 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; |
| @@ -2668,7 +2887,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2668 | )) { | 2887 | )) { |
| 2669 | .moved => |to| { | 2888 | .moved => |to| { |
| 2670 | last_zoom = z; | 2889 | last_zoom = z; |
| 2671 | setZoom(tiles[0..live], &shared, to); | 2890 | setZoom(tiles[0..live], &shared, &input, to); |
| 2672 | }, | 2891 | }, |
| 2673 | .full => setNotice(&shared, "[no room on the wall for another tile]"), | 2892 | .full => setNotice(&shared, "[no room on the wall for another tile]"), |
| 2674 | .stay => {}, | 2893 | .stay => {}, |
| @@ -2679,7 +2898,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2679 | .ignore => {}, | 2898 | .ignore => {}, |
| 2680 | .unzoom => { | 2899 | .unzoom => { |
| 2681 | last_zoom = ended; | 2900 | last_zoom = ended; |
| 2682 | setZoom(tiles[0..live], &shared, no_zoom); | 2901 | setZoom(tiles[0..live], &shared, &input, no_zoom); |
| 2683 | }, | 2902 | }, |
| 2684 | .fall_back => |back| { | 2903 | .fall_back => |back| { |
| 2685 | // A `Ctrl-\ c` the daemon refused: the tile it was | 2904 | // A `Ctrl-\ c` the daemon refused: the tile it was |
| @@ -2694,7 +2913,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2694 | tiles[ended].gone.store(true, .release); | 2913 | tiles[ended].gone.store(true, .release); |
| 2695 | std.debug.print("mux: cannot create a new session (daemon full?)\n", .{}); | 2914 | std.debug.print("mux: cannot create a new session (daemon full?)\n", .{}); |
| 2696 | setNotice(&shared, "[cannot create a new session (daemon full?)]"); | 2915 | setNotice(&shared, "[cannot create a new session (daemon full?)]"); |
| 2697 | setZoom(tiles[0..live], &shared, back); | 2916 | setZoom(tiles[0..live], &shared, &input, back); |
| 2698 | }, | 2917 | }, |
| 2699 | .finish => |how| { | 2918 | .finish => |how| { |
| 2700 | exit_code = how.code; | 2919 | exit_code = how.code; |
| @@ -2717,7 +2936,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2717 | 2936 | ||
| 2718 | const z = shared.zoom.load(.acquire); | 2937 | const z = shared.zoom.load(.acquire); |
| 2719 | if (z != no_zoom) { | 2938 | if (z != no_zoom) { |
| 2720 | const cmd = prefix.feed(b[0..n]); | 2939 | const cmd = input.prefix.feed(b[0..n]); |
| 2721 | // A BARE Ctrl-\ while there is no session to command is the | 2940 | // A BARE Ctrl-\ while there is no session to command is the |
| 2722 | // user giving up on the wait. Judged after the filter rather | 2941 | // user giving up on the wait. Judged after the filter rather |
| 2723 | // than by scanning the raw bytes, because the two readings of | 2942 | // than by scanning the raw bytes, because the two readings of |
| @@ -2726,7 +2945,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2726 | // asking for the wall — and on a wall there is one to give | 2945 | // asking for the wall — and on a wall there is one to give |
| 2727 | // them, which is what the plain client this rule came from | 2946 | // them, which is what the plain client this rule came from |
| 2728 | // never had. | 2947 | // never had. |
| 2729 | if (cmd.action == .none and prefix.pending) { | 2948 | if (cmd.action == .none and input.prefix.pending) { |
| 2730 | if (awaitingSession(&tiles[z])) |waiting| { | 2949 | if (awaitingSession(&tiles[z])) |waiting| { |
| 2731 | exit_code = 0; | 2950 | exit_code = 0; |
| 2732 | exit_msg = switch (waiting) { | 2951 | exit_msg = switch (waiting) { |
| @@ -2763,7 +2982,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2763 | _ = hydrate(alloc, tiles, present, &live, &shared, entry.key, entry.idle_ms); | 2982 | _ = hydrate(alloc, tiles, present, &live, &shared, entry.key, entry.idle_ms); |
| 2764 | } | 2983 | } |
| 2765 | last_zoom = z; | 2984 | last_zoom = z; |
| 2766 | setZoom(tiles[0..live], &shared, no_zoom); | 2985 | setZoom(tiles[0..live], &shared, &input, no_zoom); |
| 2767 | // The stripes were cut for the tiles that existed when | 2986 | // The stripes were cut for the tiles that existed when |
| 2768 | // the wall started; an unzoom re-cuts them over the | 2987 | // the wall started; an unzoom re-cuts them over the |
| 2769 | // tiles that are there NOW — the hydration's, and every | 2988 | // tiles that are there NOW — the hydration's, and every |
| @@ -2776,29 +2995,40 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2776 | }, | 2995 | }, |
| 2777 | .to => |next| { | 2996 | .to => |next| { |
| 2778 | last_zoom = z; | 2997 | last_zoom = z; |
| 2779 | setZoom(tiles[0..live], &shared, next); | 2998 | setZoom(tiles[0..live], &shared, &input, next); |
| 2780 | }, | 2999 | }, |
| 2781 | } | 3000 | } |
| 2782 | continue; | 3001 | continue; |
| 2783 | } | 3002 | } |
| 2784 | 3003 | ||
| 2785 | // Unzoomed, this terminal reports the mouse on the WALL's own claim, | 3004 | // Unzoomed, this terminal reports the mouse on the WALL's own claim, |
| 2786 | // so a read here can carry SGR reports as well as keys. They come | 3005 | // so a read here can carry SGR reports as well as keys. The filter |
| 2787 | // out AHEAD of the byte loop because a report is made of characters | 3006 | // takes the reports OUT of the byte stream, because a report is |
| 2788 | // the wall acts on: its parameters are decimal digits, and a digit | 3007 | // made of characters the wall acts on: its parameters are decimal |
| 2789 | // is a jump key in `selectKey`. One wheel spin over a stripe would | 3008 | // digits, and a digit is a jump key in `selectKey`. One wheel spin |
| 2790 | // otherwise walk the selection at random. | 3009 | // over a stripe would otherwise walk the selection at random. |
| 2791 | const report = mouse.feed(b[0..n], &mouse_out); | 3010 | const report = input.mouse.feed(b[0..n], &mouse_out); |
| 2792 | // Both halves are dropped, by decision and not by omission. There | 3011 | // The wheel is dropped, by decision and not by omission: there is |
| 2793 | // is no wall-level scrollback for a notch to move, and a press, a | 3012 | // no wall-level scrollback for a notch to move, and a user who sees |
| 2794 | // drag or a release has nothing behind it yet — a user who sees the | 3013 | // the wall answer the mouse at all will spin it over a stripe. |
| 2795 | // wall answer the mouse at all will spin the wheel over a stripe, | 3014 | // Until something is listening, nothing happening is the honest |
| 2796 | // and until something is listening, nothing happening is the honest | 3015 | // answer. |
| 2797 | // answer to that. | ||
| 2798 | _ = report.wheel; | 3016 | _ = report.wheel; |
| 2799 | _ = report.events; | ||
| 2800 | 3017 | ||
| 2801 | for (report.forward) |key| { | 3018 | // What the filter took out goes back in HERE, interleaved with the |
| 3019 | // keys in the order the terminal wrote them (`WallDrain`). Draining | ||
| 3020 | // every event first would let a click that arrived after a Return | ||
| 3021 | // zoom the tile it had just selected rather than the one the user | ||
| 3022 | // chose. | ||
| 3023 | var drain = WallDrain.init(report); | ||
| 3024 | while (drain.next()) |step| { | ||
| 3025 | const key = switch (step) { | ||
| 3026 | .mouse => |ev| { | ||
| 3027 | wallMouse(tiles[0..live], present[0..live], &shared, &input.click, ev); | ||
| 3028 | continue; | ||
| 3029 | }, | ||
| 3030 | .key => |k| k, | ||
| 3031 | }; | ||
| 2802 | if (key == 'q' or key == 0x1c) break :keys; | 3032 | if (key == 'q' or key == 0x1c) break :keys; |
| 2803 | // Both spellings of Enter: ICRNL is off, so a Return arrives | 3033 | // Both spellings of Enter: ICRNL is off, so a Return arrives |
| 2804 | // as CR, but a script or a paste can just as easily send LF. | 3034 | // as CR, but a script or a paste can just as easily send LF. |
| @@ -2811,14 +3041,11 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2811 | // An empty wall has nothing to hand the terminal to, and | 3041 | // An empty wall has nothing to hand the terminal to, and |
| 2812 | // `sel` still names the tile `x` just took away. | 3042 | // `sel` still names the tile `x` just took away. |
| 2813 | if (shared.sel >= live or !present[shared.sel]) continue :keys; | 3043 | if (shared.sel >= live or !present[shared.sel]) continue :keys; |
| 2814 | setZoom(tiles[0..live], &shared, shared.sel); | ||
| 2815 | // The rest of this read was typed at the WALL, before the | 3044 | // The rest of this read was typed at the WALL, before the |
| 2816 | // terminal changed hands — it is not the session's input. | 3045 | // terminal changed hands — it is not the session's input, |
| 2817 | // The prefix layer starts clean for the same reason, and so | 3046 | // and neither are the reports still to be drained from it. |
| 2818 | // does the mouse filter: anything it is still holding is | 3047 | // `setZoom` clears what the filters are holding. |
| 2819 | // the head of a report from the read being dropped here. | 3048 | setZoom(tiles[0..live], &shared, &input, shared.sel); |
| 2820 | prefix = .{}; | ||
| 2821 | mouse.reset(); | ||
| 2822 | continue :keys; | 3049 | continue :keys; |
| 2823 | } | 3050 | } |
| 2824 | // `x` forgets the selected tile: off the wall file, off this | 3051 | // `x` forgets the selected tile: off the wall file, off this |
| @@ -3325,12 +3552,13 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store | |||
| 3325 | }; | 3552 | }; |
| 3326 | 3553 | ||
| 3327 | var buf: [512]u8 = undefined; | 3554 | var buf: [512]u8 = undefined; |
| 3555 | var input: WallInput = .{}; | ||
| 3328 | 3556 | ||
| 3329 | // Zoomed OUT: there is no outgoing session, so there is nothing of a | 3557 | // Zoomed OUT: there is no outgoing session, so there is nothing of a |
| 3330 | // session's to take off. The wall's own screen bytes are all this may | 3558 | // session's to take off. The wall's own screen bytes are all this may |
| 3331 | // write — a release here would unset modes the user's own terminal may | 3559 | // write — a release here would unset modes the user's own terminal may |
| 3332 | // have arrived with. | 3560 | // have arrived with. |
| 3333 | setZoom(&tiles, &shared, 0); | 3561 | setZoom(&tiles, &shared, &input, 0); |
| 3334 | var out = readAvail(p[0], &buf); | 3562 | var out = readAvail(p[0], &buf); |
| 3335 | try std.testing.expect(std.mem.indexOf(u8, out, interact.session_release) == null); | 3563 | try std.testing.expect(std.mem.indexOf(u8, out, interact.session_release) == null); |
| 3336 | 3564 | ||
| @@ -3339,7 +3567,7 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store | |||
| 3339 | // `paint_mu`, ahead of the store, so the bytes are on the wire before | 3567 | // `paint_mu`, ahead of the store, so the bytes are on the wire before |
| 3340 | // any pump can read the new zoom and claim. Here that shows up as the | 3568 | // any pump can read the new zoom and claim. Here that shows up as the |
| 3341 | // release preceding the screen clear the same call writes. | 3569 | // release preceding the screen clear the same call writes. |
| 3342 | setZoom(&tiles, &shared, 1); | 3570 | setZoom(&tiles, &shared, &input, 1); |
| 3343 | out = readAvail(p[0], &buf); | 3571 | out = readAvail(p[0], &buf); |
| 3344 | const rel = std.mem.indexOf(u8, out, interact.session_release) orelse | 3572 | const rel = std.mem.indexOf(u8, out, interact.session_release) orelse |
| 3345 | return error.NoReleaseOnZoomMove; | 3573 | return error.NoReleaseOnZoomMove; |
| @@ -3349,7 +3577,7 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store | |||
| 3349 | 3577 | ||
| 3350 | // Zoom B -> the wall: same release, and this is the path the e2e | 3578 | // Zoom B -> the wall: same release, and this is the path the e2e |
| 3351 | // no-leaked-mode leg counts. | 3579 | // no-leaked-mode leg counts. |
| 3352 | setZoom(&tiles, &shared, no_zoom); | 3580 | setZoom(&tiles, &shared, &input, no_zoom); |
| 3353 | out = readAvail(p[0], &buf); | 3581 | out = readAvail(p[0], &buf); |
| 3354 | const rel_out = std.mem.indexOf(u8, out, interact.session_release) orelse | 3582 | const rel_out = std.mem.indexOf(u8, out, interact.session_release) orelse |
| 3355 | return error.NoReleaseOnUnzoom; | 3583 | return error.NoReleaseOnUnzoom; |
| @@ -3366,6 +3594,233 @@ test "setZoom writes the outgoing zoom's release, and writes it before the store | |||
| 3366 | try std.testing.expect(rel_out < arm); | 3594 | try std.testing.expect(rel_out < arm); |
| 3367 | } | 3595 | } |
| 3368 | 3596 | ||
| 3597 | test "the wall drains keys and reports in the order the terminal wrote them" { | ||
| 3598 | // `feed` returns two flat lists and `Event.at` is what re-joins them. | ||
| 3599 | // The read that costs is one holding a Return AND a click: Enter zooms | ||
| 3600 | // the SELECTED tile and a press moves the selection, so a driver that | ||
| 3601 | // drained every event first would zoom the tile the click had just | ||
| 3602 | // selected rather than the one the user chose. `at` being right and the | ||
| 3603 | // driver using it right are two different properties; this is the | ||
| 3604 | // second one. | ||
| 3605 | var f: interact.MouseFilter = .{}; | ||
| 3606 | var out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; | ||
| 3607 | |||
| 3608 | // Enter first, then the press. | ||
| 3609 | var d = WallDrain.init(f.feed("\r\x1b[<0;3;2M", &out)); | ||
| 3610 | try std.testing.expectEqual(@as(u8, '\r'), (d.next() orelse return error.NoStep).key); | ||
| 3611 | try std.testing.expectEqual( | ||
| 3612 | interact.MouseFilter.Event.Kind.press, | ||
| 3613 | (d.next() orelse return error.NoStep).mouse.kind, | ||
| 3614 | ); | ||
| 3615 | try std.testing.expect(d.next() == null); | ||
| 3616 | |||
| 3617 | // The same two bytes the other way round, and the opposite answer. | ||
| 3618 | f.reset(); | ||
| 3619 | d = WallDrain.init(f.feed("\x1b[<0;3;2M\r", &out)); | ||
| 3620 | try std.testing.expectEqual( | ||
| 3621 | interact.MouseFilter.Event.Kind.press, | ||
| 3622 | (d.next() orelse return error.NoStep).mouse.kind, | ||
| 3623 | ); | ||
| 3624 | try std.testing.expectEqual(@as(u8, '\r'), (d.next() orelse return error.NoStep).key); | ||
| 3625 | try std.testing.expect(d.next() == null); | ||
| 3626 | |||
| 3627 | // A report in the MIDDLE of the keys stays there: `1` and `j` are both | ||
| 3628 | // wall keys and the press belongs between them. | ||
| 3629 | f.reset(); | ||
| 3630 | d = WallDrain.init(f.feed("1\x1b[<0;3;1Mj", &out)); | ||
| 3631 | try std.testing.expectEqual(@as(u8, '1'), (d.next() orelse return error.NoStep).key); | ||
| 3632 | try std.testing.expectEqual( | ||
| 3633 | interact.MouseFilter.Event.Kind.press, | ||
| 3634 | (d.next() orelse return error.NoStep).mouse.kind, | ||
| 3635 | ); | ||
| 3636 | try std.testing.expectEqual(@as(u8, 'j'), (d.next() orelse return error.NoStep).key); | ||
| 3637 | try std.testing.expect(d.next() == null); | ||
| 3638 | } | ||
| 3639 | |||
| 3640 | test "a plain click selects the stripe under it, and a drag selects nothing" { | ||
| 3641 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3642 | defer std.posix.close(p[0]); | ||
| 3643 | defer std.posix.close(p[1]); | ||
| 3644 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 3645 | var tiles = [_]Tile{ | ||
| 3646 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, | ||
| 3647 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, | ||
| 3648 | }; | ||
| 3649 | var present = [_]bool{ true, true }; | ||
| 3650 | var click: Click = .{}; | ||
| 3651 | |||
| 3652 | const press: interact.MouseFilter.Event = .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }; | ||
| 3653 | const release: interact.MouseFilter.Event = .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 }; | ||
| 3654 | |||
| 3655 | // Press and release in the same cell: tmux's MouseDown1Pane. | ||
| 3656 | wallMouse(&tiles, &present, &shared, &click, press); | ||
| 3657 | // The press alone moves nothing — the selection follows the RELEASE, so | ||
| 3658 | // a press that turns into a drag never flickers the marker on its way. | ||
| 3659 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | ||
| 3660 | wallMouse(&tiles, &present, &shared, &click, release); | ||
| 3661 | try std.testing.expectEqual(@as(usize, 1), shared.sel); | ||
| 3662 | |||
| 3663 | // A drag: the pointer leaves the cell before the button comes up, so | ||
| 3664 | // the release is a drag end and not a click. Nothing consumes a drag | ||
| 3665 | // yet, and it must not quietly mean "select" in the meantime. | ||
| 3666 | moveSelection(&tiles, &shared, 0); | ||
| 3667 | wallMouse(&tiles, &present, &shared, &click, press); | ||
| 3668 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 }); | ||
| 3669 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 }); | ||
| 3670 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | ||
| 3671 | |||
| 3672 | // A click on a label bar names no line, so it moves nothing. | ||
| 3673 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .press, .button = 0, .col = 0, .row = 12, .at = 0 }); | ||
| 3674 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 0, .col = 0, .row = 12, .at = 0 }); | ||
| 3675 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | ||
| 3676 | |||
| 3677 | // Right and middle stay the terminal's own: its paste and its menu. | ||
| 3678 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .press, .button = 2, .col = 4, .row = 15, .at = 0 }); | ||
| 3679 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 2, .col = 4, .row = 15, .at = 0 }); | ||
| 3680 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | ||
| 3681 | |||
| 3682 | // A release with no press behind it — the other half arrived while a | ||
| 3683 | // zoom held the terminal, or before this wall started reading at all. | ||
| 3684 | wallMouse(&tiles, &present, &shared, &click, release); | ||
| 3685 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | ||
| 3686 | } | ||
| 3687 | |||
| 3688 | test "a zoom move drops what the wall's input filters are holding" { | ||
| 3689 | // The reset used to sit at ONE of six `setZoom` call sites and held | ||
| 3690 | // only because the other five are reachable from an already-zoomed | ||
| 3691 | // wall. It belongs to the TRANSITION: every one of them hands the | ||
| 3692 | // terminal to somebody else, and a chord half typed, a report half | ||
| 3693 | // read or a button still down at the wall is state about a screen that | ||
| 3694 | // has stopped existing. | ||
| 3695 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3696 | defer std.posix.close(p[0]); | ||
| 3697 | defer std.posix.close(p[1]); | ||
| 3698 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 3699 | var tiles = [_]Tile{ | ||
| 3700 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, | ||
| 3701 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, | ||
| 3702 | }; | ||
| 3703 | var present = [_]bool{ true, true }; | ||
| 3704 | var input: WallInput = .{}; | ||
| 3705 | |||
| 3706 | // A chord waiting for its command key, a report split across the read | ||
| 3707 | // boundary, and a button held down over a stripe. | ||
| 3708 | var chord = [_]u8{0x1c}; | ||
| 3709 | _ = input.prefix.feed(&chord); | ||
| 3710 | try std.testing.expect(input.prefix.pending); | ||
| 3711 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; | ||
| 3712 | _ = input.mouse.feed("\x1b[<0;3", &mouse_out); | ||
| 3713 | try std.testing.expect(input.mouse.len > 0); | ||
| 3714 | wallMouse(&tiles, &present, &shared, &input.click, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }); | ||
| 3715 | try std.testing.expect(input.click.down != null); | ||
| 3716 | |||
| 3717 | setZoom(&tiles, &shared, &input, 0); | ||
| 3718 | |||
| 3719 | try std.testing.expect(!input.prefix.pending); | ||
| 3720 | try std.testing.expectEqual(@as(usize, 0), input.mouse.len); | ||
| 3721 | try std.testing.expect(input.click.down == null); | ||
| 3722 | } | ||
| 3723 | |||
| 3724 | test "stripeContentRow: a label bar and the rows outside a stripe name no line" { | ||
| 3725 | const alloc = std.testing.allocator; | ||
| 3726 | const st = try layoutStripes(alloc, 3, 25); | ||
| 3727 | defer alloc.free(st); | ||
| 3728 | // 25 rows over 3 stripes: 9, 8, 8, remainder at the top. | ||
| 3729 | try std.testing.expectEqual(@as(u16, 0), st[0].top); | ||
| 3730 | try std.testing.expectEqual(@as(u16, 9), st[0].rows); | ||
| 3731 | |||
| 3732 | // The bar itself: a click on the label points at no session line, and | ||
| 3733 | // answering with the row under it would move a selection nobody asked | ||
| 3734 | // to move. | ||
| 3735 | try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[0], 0)); | ||
| 3736 | try std.testing.expectEqual(@as(?u16, 0), stripeContentRow(st[0], 1)); | ||
| 3737 | try std.testing.expectEqual(@as(?u16, 7), stripeContentRow(st[0], 8)); | ||
| 3738 | // Row 9 is the NEXT stripe's bar, not this stripe's ninth content row. | ||
| 3739 | try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[0], 9)); | ||
| 3740 | // ...and nothing above a stripe belongs to it either. | ||
| 3741 | try std.testing.expectEqual(@as(?u16, null), stripeContentRow(st[1], 8)); | ||
| 3742 | try std.testing.expectEqual(@as(?u16, 0), stripeContentRow(st[1], 10)); | ||
| 3743 | } | ||
| 3744 | |||
| 3745 | test "hitTest names the absolute row under a click, and nothing at all off a stripe" { | ||
| 3746 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; | ||
| 3747 | var tiles = [_]Tile{ | ||
| 3748 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, | ||
| 3749 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, | ||
| 3750 | }; | ||
| 3751 | // What each pump last painted. The daemon holds 100 rows above tile | ||
| 3752 | // b's viewport and the stripe was showing that grid from row 5. | ||
| 3753 | tiles[0].win = .{ .history_rows = 0, .win_start = 0 }; | ||
| 3754 | tiles[1].win = .{ .history_rows = 100, .win_start = 5 }; | ||
| 3755 | var present = [_]bool{ true, true }; | ||
| 3756 | |||
| 3757 | // Tile b's first content row: terminal row 13, grid row 5, and 100 | ||
| 3758 | // retained rows above the viewport. | ||
| 3759 | const first = hitTest(&tiles, &present, &shared, 13, 4) orelse return error.NoHit; | ||
| 3760 | try std.testing.expectEqual(@as(usize, 1), first.tile); | ||
| 3761 | try std.testing.expectEqual(@as(u32, 105), first.row); | ||
| 3762 | // Columns map 1:1 — every painter emits from column 1 with no offset. | ||
| 3763 | try std.testing.expectEqual(@as(u16, 4), first.col); | ||
| 3764 | |||
| 3765 | // ...and its last. | ||
| 3766 | const last = hitTest(&tiles, &present, &shared, 23, 0) orelse return error.NoHit; | ||
| 3767 | try std.testing.expectEqual(@as(u32, 115), last.row); | ||
| 3768 | |||
| 3769 | // Tile a, whose window sits at the grid top with no history behind it. | ||
| 3770 | const top = hitTest(&tiles, &present, &shared, 1, 0) orelse return error.NoHit; | ||
| 3771 | try std.testing.expectEqual(@as(usize, 0), top.tile); | ||
| 3772 | try std.testing.expectEqual(@as(u32, 0), top.row); | ||
| 3773 | |||
| 3774 | // Both label bars: a defined no-op, not the nearest line. | ||
| 3775 | try std.testing.expect(hitTest(&tiles, &present, &shared, 0, 0) == null); | ||
| 3776 | try std.testing.expect(hitTest(&tiles, &present, &shared, 12, 0) == null); | ||
| 3777 | // Past the last stripe — a terminal taller than the wall was cut for. | ||
| 3778 | try std.testing.expect(hitTest(&tiles, &present, &shared, 24, 0) == null); | ||
| 3779 | |||
| 3780 | // A forgotten tile keeps its stale stripe (`relayout` never compacts | ||
| 3781 | // the array), so `present` and not the geometry is what answers here. | ||
| 3782 | present[1] = false; | ||
| 3783 | try std.testing.expect(hitTest(&tiles, &present, &shared, 13, 0) == null); | ||
| 3784 | } | ||
| 3785 | |||
| 3786 | test "paintStripe publishes the window it painted" { | ||
| 3787 | // A click is resolved on the KEYBOARD thread, which has no replica in | ||
| 3788 | // reach — the `Engine` is the pump's. So the pump has to leave behind | ||
| 3789 | // the two numbers that name a line, and this asserts they describe the | ||
| 3790 | // window that actually went to the terminal rather than some other one. | ||
| 3791 | const alloc = std.testing.allocator; | ||
| 3792 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3793 | defer std.posix.close(p[0]); | ||
| 3794 | defer std.posix.close(p[1]); | ||
| 3795 | |||
| 3796 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 20, .rows = 24 }, .is_tty = true }; | ||
| 3797 | var t = Tile{ | ||
| 3798 | .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, | ||
| 3799 | // A label bar and three content rows. | ||
| 3800 | .stripe = .{ .top = 0, .rows = 4 }, | ||
| 3801 | .shared = &shared, | ||
| 3802 | .idx = 0, | ||
| 3803 | }; | ||
| 3804 | |||
| 3805 | const eng = try Engine.init(alloc, .{ .cols = 20, .rows = 5 }); | ||
| 3806 | defer eng.deinit(); | ||
| 3807 | eng.feed("row0\r\nrow1\r\nrow2\r\nrow3\r\nrow4"); | ||
| 3808 | |||
| 3809 | try std.testing.expect(paintStripe(&t, alloc, eng, 7)); | ||
| 3810 | // Three content rows over a five-row grid with the cursor on the last: | ||
| 3811 | // the window is grid rows 2..4. | ||
| 3812 | try std.testing.expectEqual(@as(u16, 2), t.win.win_start); | ||
| 3813 | // Carried through untouched — it is the daemon's count, not the | ||
| 3814 | // replica's, because absolute rows are named from the oldest row the | ||
| 3815 | // DAEMON still retains. | ||
| 3816 | try std.testing.expectEqual(@as(u32, 7), t.win.history_rows); | ||
| 3817 | |||
| 3818 | var buf: [4096]u8 = undefined; | ||
| 3819 | const out = readAvail(p[0], &buf); | ||
| 3820 | try std.testing.expect(std.mem.indexOf(u8, out, "row2") != null); | ||
| 3821 | try std.testing.expect(std.mem.indexOf(u8, out, "row1") == null); | ||
| 3822 | } | ||
| 3823 | |||
| 3369 | test "agent channels: slots fill in order, a full table refuses, and a close is announced" { | 3824 | test "agent channels: slots fill in order, a full table refuses, and a close is announced" { |
| 3370 | // The pump's table at the size its refusal path needs. The e2e proves | 3825 | // The pump's table at the size its refusal path needs. The e2e proves |
| 3371 | // one channel end to end and will never fill eight of them. | 3826 | // one channel end to end and will never fill eight of them. |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -1552,6 +1552,8 @@ cleanup() { | |||
| 1552 | rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ | 1552 | rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ |
| 1553 | "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ | 1553 | "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ |
| 1554 | "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ | 1554 | "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ |
| 1555 | "$OUT.wmcap3" "$OUT.wmcap3.err" "$OUT.wmpc3" \ | ||
| 1556 | "$OUT.wmcap4" "$OUT.wmcap4.err" "$OUT.wmpc4" \ | ||
| 1555 | "$OUT.wmstop" | 1557 | "$OUT.wmstop" |
| 1556 | # The convergence files a FAILING assert_converged leaves behind | 1558 | # The convergence files a FAILING assert_converged leaves behind |
| 1557 | # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not | 1559 | # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not |
| @@ -7569,14 +7571,17 @@ wait_grid "$SOCK50" "wma-pin" "wall mouse: session a's marker" a | |||
| 7569 | wait_grid "$SOCK50" "wmb-pin" "wall mouse: session b's marker" b | 7571 | wait_grid "$SOCK50" "wmb-pin" "wall mouse: session b's marker" b |
| 7570 | 7572 | ||
| 7571 | # A press, a drag and a release at column 3, then a wheel notch — every | 7573 | # A press, a drag and a release at column 3, then a wheel notch — every |
| 7572 | # shape the filter has to swallow. The wall opens with tile 1 (session a) | 7574 | # shape the filter has to take out of the key stream. The wall opens with |
| 7573 | # selected, and each report ENDS on a `2`: that is not decoration. The key | 7575 | # tile 1 (session a) selected and row 2 is inside tile a's own stripe, so |
| 7576 | # what the click means is "stay", which is what makes this the leg about | ||
| 7577 | # the KEYS: each report ENDS on a `2`, and that is not decoration. The key | ||
| 7574 | # loop acts on every digit as it passes, so a report ending on `1` would | 7578 | # loop acts on every digit as it passes, so a report ending on `1` would |
| 7575 | # jump to b and back to a and leave the selection where it started — | 7579 | # jump to b and back to a and leave the selection where it started — |
| 7576 | # passing this leg with the filter deleted. Measured, not reasoned about: | 7580 | # passing this leg with the filter deleted. Measured, not reasoned about: |
| 7577 | # with `\x1b[<0;2;1M` this leg passed against a loop that read the reports | 7581 | # with `\x1b[<0;2;1M` this leg passed against a loop that read the reports |
| 7578 | # as keys. Row 2 is the only parameter free to carry that `2`, since | 7582 | # as keys. Row 2 is the only parameter free to carry that `2`, since |
| 7579 | # column 3 and the button words have to spell something else. | 7583 | # column 3 and the button words have to spell something else. The legs |
| 7584 | # below are the other half: a click that MOVES the selection. | ||
| 7580 | set +e | 7585 | set +e |
| 7581 | timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap" --err "$OUT.wmcap.err" -- \ | 7586 | timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap" --err "$OUT.wmcap.err" -- \ |
| 7582 | "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc" 2>&1 <<'EOF' | 7587 | "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc" 2>&1 <<'EOF' |
| @@ -7632,9 +7637,85 @@ timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1 | |||
| 7632 | grep -q "wm-two" "$OUT.wmfb" || { | 7637 | grep -q "wm-two" "$OUT.wmfb" || { |
| 7633 | echo "e2e FAIL: wall mouse: a bare 2 did not move the selection, so the leg above proves nothing:" | 7638 | echo "e2e FAIL: wall mouse: a bare 2 did not move the selection, so the leg above proves nothing:" |
| 7634 | cat "$OUT.wmfb"; exit 1; } | 7639 | cat "$OUT.wmfb"; exit 1; } |
| 7640 | ok "a mouse report at the unzoomed wall is swallowed; the same digit typed alone still jumps" | ||
| 7641 | |||
| 7642 | # ...and now the same report SELECTS. Two tiles over 30 rows is two | ||
| 7643 | # fifteen-row stripes, each opening with a label bar, so terminal row 19 is | ||
| 7644 | # inside session b's stripe and terminal row 1 is session a's BAR. | ||
| 7645 | # | ||
| 7646 | # The row numbers are chosen the way the leg above chose its `2`. Read as | ||
| 7647 | # keys, `\x1b[<0;3;19M` offers `0`, `3`, `1` and `9`: `1` jumps to session a | ||
| 7648 | # and the rest name no tile, so a selection that ends on b cannot have got | ||
| 7649 | # there by a digit. Which session it ended on is read off the DAEMON again, | ||
| 7650 | # never off the paint. | ||
| 7651 | set +e | ||
| 7652 | timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap3" --err "$OUT.wmcap3.err" -- \ | ||
| 7653 | "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc3" 2>&1 <<'EOF' | ||
| 7654 | expect wmb-pin 20000 | ||
| 7655 | settle 700 20000 | ||
| 7656 | send \x1b[<0;3;19M\x1b[<0;3;19m | ||
| 7657 | settle 700 20000 | ||
| 7658 | send \r | ||
| 7659 | settle 700 20000 | ||
| 7660 | send printf 'wm-%s\\n' three\n | ||
| 7661 | expect wm-three 15000 | ||
| 7662 | settle 400 15000 | ||
| 7663 | send \x1cd | ||
| 7664 | waitexit 10000 | ||
| 7665 | EOF | ||
| 7666 | RC=$? | ||
| 7667 | set -e | ||
| 7668 | [ "$RC" -eq 0 ] || { | ||
| 7669 | echo "e2e FAIL: wall click: ptyclient leg exited $RC (did the click zoom anything?):" | ||
| 7670 | cat "$OUT.wmpc3"; exit 1; } | ||
| 7671 | timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1 | ||
| 7672 | timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1 | ||
| 7673 | grep -q "wm-three" "$OUT.wmfb" || { | ||
| 7674 | echo "e2e FAIL: wall click: a click in session b's stripe did not select it:" | ||
| 7675 | cat "$OUT.wmfb"; exit 1; } | ||
| 7676 | grep -q "wm-three" "$OUT.wmfa" && { | ||
| 7677 | echo "e2e FAIL: wall click: the selection stayed on session a:" | ||
| 7678 | cat "$OUT.wmfa"; exit 1; } | ||
| 7679 | |||
| 7680 | # A click on a LABEL BAR names no session line, so it moves nothing — the | ||
| 7681 | # bar is exactly where a hand rests between two stripes. Asserted against a | ||
| 7682 | # selection that is already somewhere else, because "nothing moved" is only | ||
| 7683 | # a claim when there was somewhere to move from: `2` puts the selection on | ||
| 7684 | # b, and the click that follows is on session a's bar. Its own bytes would | ||
| 7685 | # not leave it there — a `1` read as a key jumps straight to a. | ||
| 7686 | set +e | ||
| 7687 | timeout 60 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.wmcap4" --err "$OUT.wmcap4.err" -- \ | ||
| 7688 | "$MUX" wall "--sock $SOCK50#a" "--sock $SOCK50#b" > "$OUT.wmpc4" 2>&1 <<'EOF' | ||
| 7689 | expect wmb-pin 20000 | ||
| 7690 | settle 700 20000 | ||
| 7691 | send 2 | ||
| 7692 | settle 700 20000 | ||
| 7693 | send \x1b[<0;3;1M\x1b[<0;3;1m | ||
| 7694 | settle 700 20000 | ||
| 7695 | send \r | ||
| 7696 | settle 700 20000 | ||
| 7697 | send printf 'wm-%s\\n' four\n | ||
| 7698 | expect wm-four 15000 | ||
| 7699 | settle 400 15000 | ||
| 7700 | send \x1cd | ||
| 7701 | waitexit 10000 | ||
| 7702 | EOF | ||
| 7703 | RC=$? | ||
| 7704 | set -e | ||
| 7705 | [ "$RC" -eq 0 ] || { | ||
| 7706 | echo "e2e FAIL: wall click: label-bar leg exited $RC:" | ||
| 7707 | cat "$OUT.wmpc4"; exit 1; } | ||
| 7708 | timeout 20 "$MUXA" capture --sock "$SOCK50" --session a > "$OUT.wmfa" 2>&1 | ||
| 7709 | timeout 20 "$MUXA" capture --sock "$SOCK50" --session b > "$OUT.wmfb" 2>&1 | ||
| 7710 | grep -q "wm-four" "$OUT.wmfb" || { | ||
| 7711 | echo "e2e FAIL: wall click: a click on a label bar moved the selection:" | ||
| 7712 | cat "$OUT.wmfa"; exit 1; } | ||
| 7713 | grep -q "wm-four" "$OUT.wmfa" && { | ||
| 7714 | echo "e2e FAIL: wall click: a label-bar click selected the tile it belongs to:" | ||
| 7715 | cat "$OUT.wmfa"; exit 1; } | ||
| 7635 | assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop" | 7716 | assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop" |
| 7636 | D48PID="" | 7717 | D48PID="" |
| 7637 | ok "a mouse report at the unzoomed wall is swallowed; the same digit typed alone still jumps" | 7718 | ok "a click selects the stripe under it, and a click on a label bar selects nothing" |
| 7638 | 7719 | ||
| 7639 | 7720 | ||
| 7640 | # The long-lived daemon has served every scenario that wanted it; stop it | 7721 | # The long-lived daemon has served every scenario that wanted it; stop it |
| @@ -7744,16 +7825,17 @@ DPID="" | |||
| 7744 | # since both replicate the same session and hold both answers alike. The | 7825 | # since both replicate the same session and hold both answers alike. The |
| 7745 | # 59th is the `-A` preflight, and no convergence point because it never | 7826 | # 59th is the `-A` preflight, and no convergence point because it never |
| 7746 | # attaches: what it asserts on is an exit code and a message, before any | 7827 | # attaches: what it asserts on is an exit code and a message, before any |
| 7747 | # transport exists to converge. The 61st is the mouse report at the | 7828 | # transport exists to converge. The 61st and 62nd are the wall's mouse — |
| 7748 | # unzoomed wall, and no convergence point because its subject is WHICH | 7829 | # a report not read as keys, then a click that selects — and neither is a |
| 7749 | # SESSION a keystroke reached — read off two daemon captures, one of which | 7830 | # convergence point, because their subject is WHICH SESSION a keystroke |
| 7750 | # must not contain it — and a client grid compared against the daemon's | 7831 | # reached (read off two daemon captures, one of which must not contain it) |
| 7751 | # says nothing about where the wall decided to send input. It is last in | 7832 | # and a client grid compared against the daemon's says nothing about where |
| 7752 | # the file rather than beside the other wall legs on purpose: the ordinals | 7833 | # the wall decided to send input. They are last in the file rather than |
| 7753 | # in this paragraph are positions, so a scenario inserted in the middle | 7834 | # beside the other wall legs on purpose: the ordinals in this paragraph are |
| 7754 | # renumbers every sentence after it. | 7835 | # positions, so a scenario inserted in the middle renumbers every sentence |
| 7755 | [ "$OK_COUNT" = "61" ] || { | 7836 | # after it. |
| 7756 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 61 —" | 7837 | [ "$OK_COUNT" = "62" ] || { |
| 7838 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 62 —" | ||
| 7757 | echo " a scenario was added (update the pin) or silently lost" | 7839 | echo " a scenario was added (update the pin) or silently lost" |
| 7758 | exit 1 | 7840 | exit 1 |
| 7759 | } | 7841 | } |
| @@ -7761,4 +7843,4 @@ DPID="" | |||
| 7761 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" | 7843 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" |
| 7762 | exit 1 | 7844 | exit 1 |
| 7763 | } | 7845 | } |
| 7764 | echo "e2e OK (61 scenarios, 35 convergence points)" | 7846 | echo "e2e OK (62 scenarios, 35 convergence points)" |