d49ecd82
feat: a drag across a stripe highlights the lines it crossed
a73x 2026-08-22 13:13
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -280,7 +280,7 @@ const mod_table = [_]ModSpec{ | |||
| 280 | // typed-at session; the overlay machinery itself is interact's, shared — | 280 | // typed-at session; the overlay machinery itself is interact's, shared — |
| 281 | // and phase 3 promotes the tile into that core rather than growing a | 281 | // and phase 3 promotes the tile into that core rather than growing a |
| 282 | // second copy of it. | 282 | // second copy of it. |
| 283 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 283 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint", "select" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 284 | // ---- layer 5 ---- | 284 | // ---- layer 5 ---- |
| 285 | // wall owns the spelling grammar and the state file, so argv is parsed | 285 | // wall owns the spelling grammar and the state file, so argv is parsed |
| 286 | // by the SAME rules the page's POST /tiles and the restored file are — | 286 | // by the SAME rules the page's POST /tiles and the restored file are — |
docs/superpowers/specs/2026-08-21-cli-drag-selection-design.md
| Old | New | ||
|---|---|---|---|
| @@ -221,7 +221,14 @@ under `paint_mu` inside the stripe paint. **Anchors convert to absolute rows | |||
| 221 | at press time**: a terminal-row anchor silently re-selects a different line | 221 | at press time**: a terminal-row anchor silently re-selects a different line |
| 222 | when output scrolls the window mid-drag. | 222 | when output scrolls the window mid-drag. |
| 223 | 223 | ||
| 224 | **The keyboard must never paint the highlight.** Pumps repaint stripes from | 224 | **The keyboard must never paint the highlight — on the WALL.** M3 found this |
| 225 | rule is narrower than written: `Core.forward` runs on the tile's *pump* | ||
| 226 | thread, which already owns the paint and the sink, so the zoomed driver | ||
| 227 | paints its own highlight directly and needs no shared state at all. The | ||
| 228 | relay below is a wall requirement, not a universal one, and M4's zoomed path | ||
| 229 | can call `beginSelection` from where it already stands. | ||
| 230 | |||
| 231 | On the wall it holds exactly as written. Pumps repaint stripes from | ||
| 225 | the replica on every frame under `paint_mu`; anything the keyboard drew dies | 232 | the replica on every frame under `paint_mu`; anything the keyboard drew dies |
| 226 | one delta later. Highlight spans live in shared state under `paint_mu`, | 233 | one delta later. Highlight spans live in shared state under `paint_mu`, |
| 227 | written by the keyboard and *rendered by the pump* inside its own stripe | 234 | written by the keyboard and *rendered by the pump* inside its own stripe |
| @@ -250,6 +257,15 @@ doc). The pump samples `history_rows` when issuing and discards a reply whose | |||
| 250 | space is renamed; in-flight requests are saved by the watermark, but a | 257 | space is renamed; in-flight requests are saved by the watermark, but a |
| 251 | retained highlight over a resynced replica is stale coordinates. | 258 | retained highlight over a resynced replica is stale coordinates. |
| 252 | 259 | ||
| 260 | **A delta cannot paint a highlighted row**, which this spec missed entirely | ||
| 261 | and M3 found. `paintDeltaClipped` paints the rows the daemon sent AS SENT, | ||
| 262 | so a row under the inversion comes back plain and the selection grows holes | ||
| 263 | wherever the session is still writing. A held selection therefore takes the | ||
| 264 | full-repaint arm that resync and contradiction already share. The cost is | ||
| 265 | full repaints while a selection is held over a busy session — acceptable for | ||
| 266 | a transient user action, and worth remembering next to main's recent work on | ||
| 267 | not rendering rows nobody is watching. | ||
| 268 | |||
| 253 | ## Rendering the highlight | 269 | ## Rendering the highlight |
| 254 | 270 | ||
| 255 | There is no VT sequence that inverts a sub-row span of what is already on | 271 | There is no VT sequence that inverts a sub-row span of what is already on |
| @@ -266,10 +282,26 @@ pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 { | |||
| 266 | 282 | ||
| 267 | `viewportRows(y0, y1)` (`engine.zig` `viewportRows`) pins a `vt.Selection` from column 0 | 283 | `viewportRows(y0, y1)` (`engine.zig` `viewportRows`) pins a `vt.Selection` from column 0 |
| 268 | to `cols-1`; `formatSelection` (`engine.zig` `formatSelection`) renders any selection to VT | 284 | to `cols-1`; `formatSelection` (`engine.zig` `formatSelection`) renders any selection to VT |
| 269 | bytes. A span dump pins arbitrary columns instead and wraps the range in | 285 | bytes. A span dump pins arbitrary columns instead. The ghostty-vt machinery |
| 270 | `\x1b[7m` … `\x1b[27m`, neutralizing the row's own SGR inside it. The | 286 | that already handles wide cells and styling does the work. |
| 271 | ghostty-vt machinery that already handles wide cells and styling does the | 287 | |
| 272 | work. | 288 | **Corrected against the real bytes when M3 built it** — three things this |
| 289 | section had wrong: | ||
| 290 | |||
| 291 | - The bracket is `\x1b[0m\x1b[7m` … `\x1b[0m`, **not** `\x1b[7m` … `\x1b[27m`. | ||
| 292 | Without the leading reset the head piece's SGR is still active inside the | ||
| 293 | highlight, which is the exact "styled cell escapes the inversion" case the | ||
| 294 | span dump exists to prevent. And `\x1b[27m` alone leaves the terminal clean | ||
| 295 | only by accident of `.emit = .plain`, while the tail's formatter assumes it | ||
| 296 | starts from default — a reset makes that true instead of lucky. | ||
| 297 | - **The formatter's `trim` defaults to true**, so the three pieces of a span | ||
| 298 | dump cannot be joined by counting characters: a head ending in blanks | ||
| 299 | leaves the cursor somewhere its byte length does not predict. Every piece | ||
| 300 | is addressed by CHA (`CSI n G`). | ||
| 301 | - **Reusing the machinery does not conjure cells.** `trim = false` restores | ||
| 302 | *written* spaces, but cells past the end of a row do not exist and no | ||
| 303 | option invents them. A drag off the end of a short line highlights to the | ||
| 304 | text and no further, whatever column the pointer reached. | ||
| 273 | 305 | ||
| 274 | ## The clipboard cap, which fails silently today | 306 | ## The clipboard cap, which fails silently today |
| 275 | 307 | ||
| @@ -311,8 +343,17 @@ Each ships and is usable on its own. | |||
| 311 | selection. First user-visible behaviour, no selection state yet. | 343 | selection. First user-visible behaviour, no selection state yet. |
| 312 | - **M3 — drag paints.** `select.zig`, spans under `paint_mu`, pump-side | 344 | - **M3 — drag paints.** `select.zig`, spans under `paint_mu`, pump-side |
| 313 | render via the new `Engine` span dump. Highlight only; nothing copies yet. | 345 | render via the new `Engine` span dump. Highlight only; nothing copies yet. |
| 314 | - **M4 — release copies.** The ask-relay to the pump, `beginSelection`, the | 346 | - **M4 — release copies.** The ask-relay to the pump (wall only — the zoomed |
| 315 | watermark check, OSC 52 out, and the too-large refusal message. | 347 | driver is already on the pump thread), `beginSelection`, the watermark |
| 348 | check, OSC 52 out, and the too-large refusal message. | ||
| 349 | |||
| 350 | M4 inherits one inconsistency it should decide rather than discover: the | ||
| 351 | highlight shows where the hand went, but `extractSelection` sets | ||
| 352 | `.trim = true`, so a selection ending in *written* blanks copies less than | ||
| 353 | it highlighted. Accepted rather than fixed — both terminals measured on | ||
| 354 | 2026-08-21 trim trailing whitespace on copy too, and `.trim` is the | ||
| 355 | contract muxweb already shares, so changing it would change the browser's | ||
| 356 | answer to match a CLI edge case nobody asked for. | ||
| 316 | 357 | ||
| 317 | ## Deliberate exclusions | 358 | ## Deliberate exclusions |
| 318 | 359 | ||
src/select.zig
| Old | New | ||
|---|---|---|---|
| @@ -139,6 +139,16 @@ pub const Drag = struct { | |||
| 139 | self.* = .{}; | 139 | self.* = .{}; |
| 140 | } | 140 | } |
| 141 | 141 | ||
| 142 | /// Which tile this drag belongs to, or null when there is no drag. | ||
| 143 | /// | ||
| 144 | /// A press that has not moved yet counts: its anchor is already on a | ||
| 145 | /// tile, and a caller dropping that tile's coordinates has to drop it | ||
| 146 | /// too. That is the difference from `range`, which answers only about | ||
| 147 | /// what is on screen. | ||
| 148 | pub fn on(self: *const Drag) ?usize { | ||
| 149 | return if (self.phase == .idle) null else self.anchor.tile; | ||
| 150 | } | ||
| 151 | |||
| 142 | /// The selection as an ordered pair, or null while there is none. | 152 | /// The selection as an ordered pair, or null while there is none. |
| 143 | pub fn range(self: *const Drag) ?Range { | 153 | pub fn range(self: *const Drag) ?Range { |
| 144 | return switch (self.phase) { | 154 | return switch (self.phase) { |
| @@ -192,6 +202,23 @@ test "select: a press alone is a click, and highlights nothing on its way" { | |||
| 192 | try std.testing.expect(d.range() == null); | 202 | try std.testing.expect(d.range() == null); |
| 193 | } | 203 | } |
| 194 | 204 | ||
| 205 | test "select: a drag names its tile from the press, before it is a selection" { | ||
| 206 | var d: Drag = .{}; | ||
| 207 | try std.testing.expect(d.on() == null); | ||
| 208 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 }); | ||
| 209 | // Pressed, not yet dragged: nothing to paint, and still a tile whose | ||
| 210 | // coordinates a caller may have to drop. | ||
| 211 | try std.testing.expect(d.range() == null); | ||
| 212 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 213 | d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 }); | ||
| 214 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 215 | _ = d.release(); | ||
| 216 | // Still held after the button came up, which is what a highlight is. | ||
| 217 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 218 | d.clear(); | ||
| 219 | try std.testing.expect(d.on() == null); | ||
| 220 | } | ||
| 221 | |||
| 195 | test "select: a press on nothing selectable is nothing at all" { | 222 | test "select: a press on nothing selectable is nothing at all" { |
| 196 | var d: Drag = .{}; | 223 | var d: Drag = .{}; |
| 197 | d.press(.{ .row = 0, .col = 3 }, null); | 224 | d.press(.{ .row = 0, .col = 3 }, null); |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -101,6 +101,7 @@ const sockpath = @import("sockpath"); | |||
| 101 | const proxy = @import("proxy"); | 101 | const proxy = @import("proxy"); |
| 102 | const Engine = @import("engine").Engine; | 102 | const Engine = @import("engine").Engine; |
| 103 | const paint = @import("paint"); | 103 | const paint = @import("paint"); |
| 104 | const select = @import("select"); | ||
| 104 | // Counters ride out through `Shared` because a detached pump never reaches | 105 | // Counters ride out through `Shared` because a detached pump never reaches |
| 105 | // a `Core.deinit`. | 106 | // a `Core.deinit`. |
| 106 | // The chord table and the prediction hooks a zoomed tile shares with the | 107 | // The chord table and the prediction hooks a zoomed tile shares with the |
| @@ -314,6 +315,19 @@ const Shared = struct { | |||
| 314 | /// it against what they last painted at, which is the only thing that | 315 | /// it against what they last painted at, which is the only thing that |
| 315 | /// repaints a stripe whose session sent no new frame meanwhile. | 316 | /// repaints a stripe whose session sent no new frame meanwhile. |
| 316 | repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), | 317 | repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), |
| 318 | /// The text selection being dragged over a stripe, and the highlight | ||
| 319 | /// standing after it. | ||
| 320 | /// | ||
| 321 | /// SHARED, and under `paint_mu`, because the two halves of a highlight | ||
| 322 | /// belong to different threads. The keyboard is its only writer — it | ||
| 323 | /// is the thread that reads the mouse — and every pump is a reader: | ||
| 324 | /// a stripe is repainted from its replica on every frame, so anything | ||
| 325 | /// the keyboard drew itself would die one delta later. The keyboard | ||
| 326 | /// makes it prompt the way `setZoom` does, by bumping `repaint_gen` | ||
| 327 | /// and ringing. The doorbell coalesces motion-rate updates for free. | ||
| 328 | /// | ||
| 329 | /// Precedent: `sel` is under this lock for exactly the same reason. | ||
| 330 | drag: select.Drag = .{}, | ||
| 317 | }; | 331 | }; |
| 318 | 332 | ||
| 319 | /// The stripe window a paint used, in the only terms a click can be | 333 | /// The stripe window a paint used, in the only terms a click can be |
| @@ -485,20 +499,15 @@ fn stripeContentRow(s: Stripe, row: u16) ?u16 { | |||
| 485 | return if (off < s.rows -| 1) off else null; | 499 | return if (off < s.rows -| 1) off else null; |
| 486 | } | 500 | } |
| 487 | 501 | ||
| 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. | 502 | /// Which session line a zero-based terminal (row, col) points at. |
| 501 | /// | 503 | /// |
| 504 | /// `select.Hit` and not a spelling of its own: it is the same triple the | ||
| 505 | /// drag machine takes, and a second one would be a conversion nobody | ||
| 506 | /// needs. Absolute rows, counted from the oldest row the daemon still | ||
| 507 | /// retains — the coordinate space `protocol.SelectionReq` speaks — and a | ||
| 508 | /// terminal column, which is a grid column too: every painter emits from | ||
| 509 | /// column 1 with no x-offset, and DECAWM-off clips at the right edge. | ||
| 510 | /// | ||
| 502 | /// Under `paint_mu` because `Tile.stripe` and `Tile.win` are both written | 511 | /// 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 | 512 | /// under it: a re-layout landing between reading one and the other would |
| 504 | /// resolve the click against a geometry that never existed. | 513 | /// resolve the click against a geometry that never existed. |
| @@ -506,7 +515,7 @@ const Hit = struct { | |||
| 506 | /// `present` and not the geometry decides whether a tile is there at all — | 515 | /// `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 | 516 | /// `relayout` never compacts the tile array, so a forgotten tile keeps the |
| 508 | /// stripe it had when it left. | 517 | /// stripe it had when it left. |
| 509 | fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?Hit { | 518 | fn hitTest(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?select.Hit { |
| 510 | shared.paint_mu.lock(); | 519 | shared.paint_mu.lock(); |
| 511 | defer shared.paint_mu.unlock(); | 520 | defer shared.paint_mu.unlock(); |
| 512 | for (tiles, present) |*t, p| { | 521 | for (tiles, present) |*t, p| { |
| @@ -559,26 +568,14 @@ const WallDrain = struct { | |||
| 559 | } | 568 | } |
| 560 | }; | 569 | }; |
| 561 | 570 | ||
| 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. | 571 | /// What a mouse report does at an UNZOOMED wall. |
| 577 | /// | 572 | /// |
| 578 | /// A plain click — press and release with the pointer never leaving the | 573 | /// 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 | 574 | /// cell — moves the selection to the stripe under it. That is tmux's |
| 580 | /// `MouseDown1Pane -> select-pane`, deliberately, because it is what the | 575 | /// `MouseDown1Pane -> select-pane`, deliberately, because it is what the |
| 581 | /// user already has in their hands. A drag does nothing yet. | 576 | /// user already has in their hands. A DRAG highlights the lines it |
| 577 | /// crossed, inside the stripe it started in, and leaves that highlight | ||
| 578 | /// standing when the button comes up. | ||
| 582 | /// | 579 | /// |
| 583 | /// The selection follows the RELEASE and not the press, so a press that | 580 | /// 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 | 581 | /// turns out to be the start of a drag never flickers the marker on its |
| @@ -588,36 +585,55 @@ const Click = struct { | |||
| 588 | /// stealing either would be a surprise the wall has no answer for. The | 585 | /// 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 | 586 | /// 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. | 587 | /// motion bit included, so it is the low two bits that name the button. |
| 588 | /// | ||
| 589 | /// The keyboard never PAINTS the highlight. It writes the drag under | ||
| 590 | /// `paint_mu` and rings, and the pumps draw it inside the stripe paints | ||
| 591 | /// they were doing anyway (`paintStripe`) — anything drawn from here | ||
| 592 | /// would be overwritten by the next frame from the session. | ||
| 591 | fn wallMouse( | 593 | fn wallMouse( |
| 592 | tiles: []Tile, | 594 | tiles: []Tile, |
| 593 | present: []const bool, | 595 | present: []const bool, |
| 594 | shared: *Shared, | 596 | shared: *Shared, |
| 595 | click: *Click, | ||
| 596 | ev: interact.MouseFilter.Event, | 597 | ev: interact.MouseFilter.Event, |
| 597 | ) void { | 598 | ) void { |
| 598 | if (ev.button & 0b11 != 0) return; | 599 | if (ev.button & 0b11 != 0) return; |
| 599 | switch (ev.kind) { | 600 | // Resolved BEFORE the lock and outside it, because `hitTest` takes the |
| 600 | .press => click.* = .{ | 601 | // same mutex. Nothing can invalidate the answer in between: the only |
| 601 | .down = hitTest(tiles, present, shared, ev.row, ev.col), | 602 | // thread that re-cuts stripes is this one. |
| 602 | .row = ev.row, | 603 | const hit = hitTest(tiles, present, shared, ev.row, ev.col); |
| 603 | .col = ev.col, | 604 | const cell: select.Cell = .{ .row = ev.row, .col = ev.col }; |
| 604 | }, | 605 | |
| 605 | // Cell granularity, not pixel: `?1002h` reports motion when the | 606 | var ended: select.Release = .nothing; |
| 606 | // pointer changes CELL, and a hand that trembles inside one cell | 607 | var moved = false; |
| 607 | // is still pointing at one line. | 608 | { |
| 608 | .motion => if (ev.row != click.row or ev.col != click.col) { | 609 | shared.paint_mu.lock(); |
| 609 | click.dragged = true; | 610 | defer shared.paint_mu.unlock(); |
| 610 | }, | 611 | const was = shared.drag.range(); |
| 611 | .release => { | 612 | switch (ev.kind) { |
| 612 | const was = click.*; | 613 | .press => shared.drag.press(cell, hit), |
| 613 | click.* = .{}; | 614 | .motion => shared.drag.motion(cell, hit), |
| 614 | if (was.dragged) return; | 615 | .release => ended = shared.drag.release(), |
| 615 | const hit = was.down orelse return; | 616 | } |
| 616 | // The press may have landed on a tile `x` has taken away | 617 | moved = !std.meta.eql(was, shared.drag.range()); |
| 617 | // since — the same guard `Enter` keeps for the same reason. | 618 | } |
| 618 | if (hit.tile < present.len and present[hit.tile]) | 619 | |
| 619 | moveSelection(tiles, shared, hit.tile); | 620 | // The highlight changed, so every stripe is now a frame out of date. |
| 620 | }, | 621 | // One bump and one round of doorbells, exactly as `setZoom` does it — |
| 622 | // and the doorbell is what makes a drag feel like a drag rather than | ||
| 623 | // like one poll timeout per cell. | ||
| 624 | if (moved) { | ||
| 625 | _ = shared.repaint_gen.fetchAdd(1, .release); | ||
| 626 | for (tiles, present) |*t, p| { | ||
| 627 | if (p) ring(t); | ||
| 628 | } | ||
| 629 | } | ||
| 630 | |||
| 631 | if (ended == .click) { | ||
| 632 | // The press may have landed on a tile `x` has taken away since — | ||
| 633 | // the same guard `Enter` keeps for the same reason. | ||
| 634 | const at = ended.click; | ||
| 635 | if (at.tile < present.len and present[at.tile]) | ||
| 636 | moveSelection(tiles, shared, at.tile); | ||
| 621 | } | 637 | } |
| 622 | } | 638 | } |
| 623 | 639 | ||
| @@ -637,14 +653,20 @@ const WallInput = struct { | |||
| 637 | /// bytes it is handed zoomed, this one runs over the bytes that arrive | 653 | /// bytes it is handed zoomed, this one runs over the bytes that arrive |
| 638 | /// UNZOOMED, which the wall's own modes are what produce. | 654 | /// UNZOOMED, which the wall's own modes are what produce. |
| 639 | mouse: interact.MouseFilter = .{}, | 655 | mouse: interact.MouseFilter = .{}, |
| 640 | click: Click = .{}, | ||
| 641 | 656 | ||
| 642 | fn reset(self: *WallInput) void { | 657 | /// The drag itself lives in `Shared` — the pumps have to read it to |
| 658 | /// paint it — but it is dropped HERE, with the rest, because it is | ||
| 659 | /// dropped for the same reason: the screen it describes has stopped | ||
| 660 | /// existing, and its anchor names a stripe that is about to belong to | ||
| 661 | /// nobody. | ||
| 662 | fn reset(self: *WallInput, shared: *Shared) void { | ||
| 643 | self.prefix = .{}; | 663 | self.prefix = .{}; |
| 644 | // Whatever the filter still holds is the head of a report from the | 664 | // Whatever the filter still holds is the head of a report from the |
| 645 | // read being dropped here. | 665 | // read being dropped here. |
| 646 | self.mouse.reset(); | 666 | self.mouse.reset(); |
| 647 | self.click = .{}; | 667 | shared.paint_mu.lock(); |
| 668 | defer shared.paint_mu.unlock(); | ||
| 669 | shared.drag.clear(); | ||
| 648 | } | 670 | } |
| 649 | }; | 671 | }; |
| 650 | 672 | ||
| @@ -833,7 +855,18 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u | |||
| 833 | .history_rows = history_rows, | 855 | .history_rows = history_rows, |
| 834 | .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), | 856 | .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), |
| 835 | }; | 857 | }; |
| 836 | paint.renderStripe(alloc, eng, t.stripe.top + 1, view, .{}, t.shared.out_fd) catch {}; | 858 | // The highlight is drawn HERE and never by the keyboard. A stripe is |
| 859 | // repainted from its replica on every frame, so an inversion the | ||
| 860 | // keyboard wrote onto the terminal would last exactly until the next | ||
| 861 | // delta; the keyboard writes the drag under this lock instead and | ||
| 862 | // rings, and this paint — the one that would have erased it — is the | ||
| 863 | // one that draws it. | ||
| 864 | const hl: interact.Highlight = .{ | ||
| 865 | .drag = &t.shared.drag, | ||
| 866 | .tile = t.idx, | ||
| 867 | .history_rows = history_rows, | ||
| 868 | }; | ||
| 869 | paint.renderStripe(alloc, eng, t.stripe.top + 1, view, hl.sink(), t.shared.out_fd) catch {}; | ||
| 837 | return true; | 870 | return true; |
| 838 | } | 871 | } |
| 839 | 872 | ||
| @@ -1261,13 +1294,34 @@ fn redial( | |||
| 1261 | transport.* = dial(alloc, t, target) orelse return false; | 1294 | transport.* = dial(alloc, t, target) orelse return false; |
| 1262 | // Clears `state_since_attach` (so the next exit_status is read as a | 1295 | // Clears `state_since_attach` (so the next exit_status is read as a |
| 1263 | // refusal again) and drops speculation made against a connection that | 1296 | // refusal again) and drops speculation made against a connection that |
| 1264 | // no longer exists. | 1297 | // no longer exists — the zoomed Core's own highlight with it. |
| 1265 | core.reattached(); | 1298 | core.reattached(); |
| 1299 | dropDragOver(t); | ||
| 1266 | const have = core.rep.attachArgs(); | 1300 | const have = core.rep.attachArgs(); |
| 1267 | sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; | 1301 | sendAttach(t, transport, have.have_seq, have.have_epoch) catch return false; |
| 1268 | return true; | 1302 | return true; |
| 1269 | } | 1303 | } |
| 1270 | 1304 | ||
| 1305 | /// Drop the WALL's highlight, if it is over this tile. | ||
| 1306 | /// | ||
| 1307 | /// The pump's own clear, and the third of three: the keyboard's two are | ||
| 1308 | /// about the screen changing shape (`WallInput.reset`, `relayout`), and | ||
| 1309 | /// this one is about the coordinates changing meaning. Absolute rows count | ||
| 1310 | /// from the oldest row the daemon still retains, and the resync a | ||
| 1311 | /// reconnect brings renames that space outright — a highlight kept across | ||
| 1312 | /// it inverts rows nobody selected. | ||
| 1313 | /// | ||
| 1314 | /// Only this tile's. A neighbour reconnecting says nothing about a | ||
| 1315 | /// selection over a session that never went away. | ||
| 1316 | /// | ||
| 1317 | /// Written from the pump because the pump is where a reconnect is noticed, | ||
| 1318 | /// under the same lock the keyboard writes the drag with. | ||
| 1319 | fn dropDragOver(t: *Tile) void { | ||
| 1320 | t.shared.paint_mu.lock(); | ||
| 1321 | defer t.shared.paint_mu.unlock(); | ||
| 1322 | if (t.shared.drag.on() == t.idx) t.shared.drag.clear(); | ||
| 1323 | } | ||
| 1324 | |||
| 1271 | /// One tile's life: dial → attach → replay frames into its Core → repaint. | 1325 | /// One tile's life: dial → attach → replay frames into its Core → repaint. |
| 1272 | /// Runs on its own thread (see module header). On transport death: | 1326 | /// Runs on its own thread (see module header). On transport death: |
| 1273 | /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch, | 1327 | /// reconnect on the CLI's backoff schedule, quoting have_seq/have_epoch, |
| @@ -1992,7 +2046,7 @@ fn setZoom(tiles: []Tile, shared: *Shared, input: *WallInput, next: usize) void | |||
| 1992 | // are mid-way through belongs to a screen that is about to stop | 2046 | // 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, | 2047 | // existing. Here rather than at the call sites: this is the transition, |
| 1994 | // and there are six of them. | 2048 | // and there are six of them. |
| 1995 | input.reset(); | 2049 | input.reset(shared); |
| 1996 | shared.paint_mu.lock(); | 2050 | shared.paint_mu.lock(); |
| 1997 | defer shared.paint_mu.unlock(); | 2051 | defer shared.paint_mu.unlock(); |
| 1998 | // See the note above: the outgoing session's modes come off HERE, on | 2052 | // See the note above: the outgoing session's modes come off HERE, on |
| @@ -2125,6 +2179,10 @@ fn relayout( | |||
| 2125 | shared.paint_mu.lock(); | 2179 | shared.paint_mu.lock(); |
| 2126 | defer shared.paint_mu.unlock(); | 2180 | defer shared.paint_mu.unlock(); |
| 2127 | shared.sel = sel; | 2181 | shared.sel = sel; |
| 2182 | // The stripes are about to be re-cut, so an anchor resolved against | ||
| 2183 | // the old ones names a line on a stripe that is moving out from under | ||
| 2184 | // it. Covers `x` as well, which ends here. | ||
| 2185 | shared.drag.clear(); | ||
| 2128 | 2186 | ||
| 2129 | var live: usize = 0; | 2187 | var live: usize = 0; |
| 2130 | for (present) |p| { | 2188 | for (present) |p| { |
| @@ -3024,7 +3082,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 3024 | while (drain.next()) |step| { | 3082 | while (drain.next()) |step| { |
| 3025 | const key = switch (step) { | 3083 | const key = switch (step) { |
| 3026 | .mouse => |ev| { | 3084 | .mouse => |ev| { |
| 3027 | wallMouse(tiles[0..live], present[0..live], &shared, &input.click, ev); | 3085 | wallMouse(tiles[0..live], present[0..live], &shared, ev); |
| 3028 | continue; | 3086 | continue; |
| 3029 | }, | 3087 | }, |
| 3030 | .key => |k| k, | 3088 | .key => |k| k, |
| @@ -3637,7 +3695,14 @@ test "the wall drains keys and reports in the order the terminal wrote them" { | |||
| 3637 | try std.testing.expect(d.next() == null); | 3695 | try std.testing.expect(d.next() == null); |
| 3638 | } | 3696 | } |
| 3639 | 3697 | ||
| 3640 | test "a plain click selects the stripe under it, and a drag selects nothing" { | 3698 | /// Everything sitting in the wall's output pipe right now. Non-blocking, |
| 3699 | /// so an empty pipe reads as empty rather than parking the suite. | ||
| 3700 | fn drainWallPipe(fd: std.posix.fd_t, buf: []u8) []const u8 { | ||
| 3701 | const n = std.posix.read(fd, buf) catch return ""; | ||
| 3702 | return buf[0..n]; | ||
| 3703 | } | ||
| 3704 | |||
| 3705 | test "a plain click selects the stripe under it, and a drag highlights instead" { | ||
| 3641 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | 3706 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); |
| 3642 | defer std.posix.close(p[0]); | 3707 | defer std.posix.close(p[0]); |
| 3643 | defer std.posix.close(p[1]); | 3708 | defer std.posix.close(p[1]); |
| @@ -3647,42 +3712,212 @@ test "a plain click selects the stripe under it, and a drag selects nothing" { | |||
| 3647 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, | 3712 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, |
| 3648 | }; | 3713 | }; |
| 3649 | var present = [_]bool{ true, true }; | 3714 | var present = [_]bool{ true, true }; |
| 3650 | var click: Click = .{}; | 3715 | // Both stripes have painted once, so a terminal row resolves to a line. |
| 3716 | tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; | ||
| 3717 | tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; | ||
| 3651 | 3718 | ||
| 3652 | const press: interact.MouseFilter.Event = .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }; | 3719 | 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 }; | 3720 | const release: interact.MouseFilter.Event = .{ .kind = .release, .button = 0, .col = 4, .row = 15, .at = 0 }; |
| 3654 | 3721 | ||
| 3655 | // Press and release in the same cell: tmux's MouseDown1Pane. | 3722 | // Press and release in the same cell: tmux's MouseDown1Pane. |
| 3656 | wallMouse(&tiles, &present, &shared, &click, press); | 3723 | wallMouse(&tiles, &present, &shared, press); |
| 3657 | // The press alone moves nothing — the selection follows the RELEASE, so | 3724 | // 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. | 3725 | // a press that turns into a drag never flickers the marker on its way. |
| 3659 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | 3726 | try std.testing.expectEqual(@as(usize, 0), shared.sel); |
| 3660 | wallMouse(&tiles, &present, &shared, &click, release); | 3727 | // ...and highlights nothing either, for the same reason. |
| 3728 | try std.testing.expect(shared.drag.range() == null); | ||
| 3729 | wallMouse(&tiles, &present, &shared, release); | ||
| 3661 | try std.testing.expectEqual(@as(usize, 1), shared.sel); | 3730 | try std.testing.expectEqual(@as(usize, 1), shared.sel); |
| 3662 | 3731 | ||
| 3663 | // A drag: the pointer leaves the cell before the button comes up, so | 3732 | // 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 | 3733 | // the release is a drag end and not a click. It must not quietly mean |
| 3665 | // yet, and it must not quietly mean "select" in the meantime. | 3734 | // "select" — it means "highlight", and the marker stays where it was. |
| 3666 | moveSelection(&tiles, &shared, 0); | 3735 | moveSelection(&tiles, &shared, 0); |
| 3667 | wallMouse(&tiles, &present, &shared, &click, press); | 3736 | wallMouse(&tiles, &present, &shared, press); |
| 3668 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 }); | 3737 | wallMouse(&tiles, &present, &shared, .{ .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 }); | 3738 | wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 9, .row = 17, .at = 0 }); |
| 3670 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | 3739 | try std.testing.expectEqual(@as(usize, 0), shared.sel); |
| 3671 | 3740 | // Stripe b, whose window began at grid row 3 with 200 rows behind it: | |
| 3672 | // A click on a label bar names no line, so it moves nothing. | 3741 | // terminal row 15 is the third content line of that stripe. |
| 3673 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .press, .button = 0, .col = 0, .row = 12, .at = 0 }); | 3742 | const held = shared.drag.range().?; |
| 3674 | wallMouse(&tiles, &present, &shared, &click, .{ .kind = .release, .button = 0, .col = 0, .row = 12, .at = 0 }); | 3743 | try std.testing.expectEqual(@as(usize, 1), held.from.tile); |
| 3744 | try std.testing.expectEqual(@as(u32, 205), held.from.row); | ||
| 3745 | try std.testing.expectEqual(@as(u16, 4), held.from.col); | ||
| 3746 | try std.testing.expectEqual(@as(u32, 207), held.to.row); | ||
| 3747 | try std.testing.expectEqual(@as(u16, 9), held.to.col); | ||
| 3748 | |||
| 3749 | // A click on a label bar names no line, so it moves nothing — and puts | ||
| 3750 | // the highlight away, which is what a press anywhere does. | ||
| 3751 | wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 0, .row = 12, .at = 0 }); | ||
| 3752 | wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 0, .col = 0, .row = 12, .at = 0 }); | ||
| 3675 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | 3753 | try std.testing.expectEqual(@as(usize, 0), shared.sel); |
| 3754 | try std.testing.expect(shared.drag.range() == null); | ||
| 3676 | 3755 | ||
| 3677 | // Right and middle stay the terminal's own: its paste and its menu. | 3756 | // 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 }); | 3757 | wallMouse(&tiles, &present, &shared, .{ .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 }); | 3758 | wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 34, .col = 9, .row = 17, .at = 0 }); |
| 3759 | wallMouse(&tiles, &present, &shared, .{ .kind = .release, .button = 2, .col = 9, .row = 17, .at = 0 }); | ||
| 3680 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | 3760 | try std.testing.expectEqual(@as(usize, 0), shared.sel); |
| 3761 | try std.testing.expect(shared.drag.range() == null); | ||
| 3681 | 3762 | ||
| 3682 | // A release with no press behind it — the other half arrived while a | 3763 | // 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. | 3764 | // zoom held the terminal, or before this wall started reading at all. |
| 3684 | wallMouse(&tiles, &present, &shared, &click, release); | 3765 | wallMouse(&tiles, &present, &shared, release); |
| 3685 | try std.testing.expectEqual(@as(usize, 0), shared.sel); | 3766 | try std.testing.expectEqual(@as(usize, 0), shared.sel); |
| 3767 | try std.testing.expect(shared.drag.range() == null); | ||
| 3768 | } | ||
| 3769 | |||
| 3770 | test "a drag at the wall stays in the stripe it started in, and rings for a repaint" { | ||
| 3771 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3772 | defer std.posix.close(p[0]); | ||
| 3773 | defer std.posix.close(p[1]); | ||
| 3774 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 3775 | var tiles = [_]Tile{ | ||
| 3776 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, | ||
| 3777 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, | ||
| 3778 | }; | ||
| 3779 | var present = [_]bool{ true, true }; | ||
| 3780 | for (&tiles) |*t| { | ||
| 3781 | const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3782 | t.wake_r = pipe[0]; | ||
| 3783 | t.wake_w = pipe[1]; | ||
| 3784 | } | ||
| 3785 | defer for (&tiles) |*t| { | ||
| 3786 | std.posix.close(t.wake_r); | ||
| 3787 | std.posix.close(t.wake_w); | ||
| 3788 | }; | ||
| 3789 | tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; | ||
| 3790 | tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; | ||
| 3791 | |||
| 3792 | const gen0 = shared.repaint_gen.load(.acquire); | ||
| 3793 | wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 }); | ||
| 3794 | // A press changes no highlight, so it costs no repaint: a wall that | ||
| 3795 | // bumped the generation on every press would redraw every stripe for | ||
| 3796 | // a click. | ||
| 3797 | try std.testing.expectEqual(gen0, shared.repaint_gen.load(.acquire)); | ||
| 3798 | |||
| 3799 | // Down into the NEXT stripe. It is a drag, and its far end stops at | ||
| 3800 | // the last line of the tile it started in — a selection that leaked | ||
| 3801 | // into the neighbour would ask the wrong session for its text. | ||
| 3802 | wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 9, .at = 0 }); | ||
| 3803 | const grew = shared.repaint_gen.load(.acquire); | ||
| 3804 | // The move that put a highlight on screen rang for a repaint. The | ||
| 3805 | // PUMPS draw it, so without the bump and the doorbell a drag would | ||
| 3806 | // appear one poll timeout per cell late — or never, over a session | ||
| 3807 | // quiet enough to send no frames of its own. | ||
| 3808 | try std.testing.expectEqual(gen0 + 1, grew); | ||
| 3809 | var bell: [8]u8 = undefined; | ||
| 3810 | try std.testing.expect(try std.posix.read(tiles[0].wake_r, &bell) > 0); | ||
| 3811 | try std.testing.expect(try std.posix.read(tiles[1].wake_r, &bell) > 0); | ||
| 3812 | |||
| 3813 | wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 2, .row = 20, .at = 0 }); | ||
| 3814 | const r = shared.drag.range().?; | ||
| 3815 | try std.testing.expectEqual(@as(usize, 0), r.from.tile); | ||
| 3816 | try std.testing.expectEqual(@as(u32, 102), r.from.row); | ||
| 3817 | try std.testing.expectEqual(@as(u32, 108), r.to.row); | ||
| 3818 | try std.testing.expectEqual(@as(u16, 7), r.to.col); | ||
| 3819 | // ...and the move that left the tile changed nothing on screen, so it | ||
| 3820 | // rang for nothing: the far end was already parked at that edge. | ||
| 3821 | try std.testing.expectEqual(grew, shared.repaint_gen.load(.acquire)); | ||
| 3822 | try std.testing.expectError(error.WouldBlock, std.posix.read(tiles[0].wake_r, &bell)); | ||
| 3823 | |||
| 3824 | // The highlight is one tile's: the stripe it did not start in shows | ||
| 3825 | // nothing, whatever row is asked about. | ||
| 3826 | try std.testing.expect(shared.drag.span(0, 105, 80) != null); | ||
| 3827 | try std.testing.expect(shared.drag.span(1, 105, 80) == null); | ||
| 3828 | } | ||
| 3829 | |||
| 3830 | test "a stripe paints its own highlight and nobody else's" { | ||
| 3831 | const alloc = std.testing.allocator; | ||
| 3832 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3833 | defer std.posix.close(p[0]); | ||
| 3834 | defer std.posix.close(p[1]); | ||
| 3835 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 20, .rows = 8 }, .is_tty = true }; | ||
| 3836 | var tiles = [_]Tile{ | ||
| 3837 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 4 }, .shared = &shared, .idx = 0 }, | ||
| 3838 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 4, .rows = 4 }, .shared = &shared, .idx = 1 }, | ||
| 3839 | }; | ||
| 3840 | var present = [_]bool{ true, true }; | ||
| 3841 | var eng = try Engine.init(alloc, .{ .cols = 20, .rows = 3 }); | ||
| 3842 | defer eng.deinit(); | ||
| 3843 | eng.feed("row-zero\r\nrow-one\r\nrow-two"); | ||
| 3844 | |||
| 3845 | // Both stripes have painted, so a click resolves. Tile a's window sits | ||
| 3846 | // at grid row 0 with 100 rows of history behind it. | ||
| 3847 | tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; | ||
| 3848 | tiles[1].win = .{ .history_rows = 100, .win_start = 0 }; | ||
| 3849 | // Drag across tile a's second content line, columns 2..5. | ||
| 3850 | wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 2, .row = 2, .at = 0 }); | ||
| 3851 | wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 5, .row = 2, .at = 0 }); | ||
| 3852 | |||
| 3853 | var buf: [8192]u8 = undefined; | ||
| 3854 | try std.testing.expect(paintStripe(&tiles[0], alloc, eng, 100)); | ||
| 3855 | const mine = drainWallPipe(p[0], &buf); | ||
| 3856 | // Grid row 1 of a's own replica, inverted from column 3 (1-based). | ||
| 3857 | try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, mine, "\x1b[7m")); | ||
| 3858 | try std.testing.expect(std.mem.indexOf(u8, mine, "\x1b[3G\x1b[0m\x1b[7m") != null); | ||
| 3859 | // At the stripe's own rows: content line two is terminal row 3. | ||
| 3860 | try std.testing.expect(std.mem.indexOf(u8, mine, "\x1b[3;1H\x1b[2K\x1b[0mro\x1b[3G") != null); | ||
| 3861 | |||
| 3862 | // The neighbour paints the same replica at its own offset, with NO | ||
| 3863 | // inversion: the highlight belongs to the tile the press landed in. | ||
| 3864 | try std.testing.expect(paintStripe(&tiles[1], alloc, eng, 100)); | ||
| 3865 | const theirs = drainWallPipe(p[0], &buf); | ||
| 3866 | try std.testing.expect(std.mem.indexOf(u8, theirs, "row-one") != null); | ||
| 3867 | try std.testing.expect(std.mem.indexOf(u8, theirs, "\x1b[7m") == null); | ||
| 3868 | } | ||
| 3869 | |||
| 3870 | test "a reconnect drops the highlight over its own tile, and only its own" { | ||
| 3871 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3872 | defer std.posix.close(p[0]); | ||
| 3873 | defer std.posix.close(p[1]); | ||
| 3874 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 3875 | var tiles = [_]Tile{ | ||
| 3876 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, | ||
| 3877 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, | ||
| 3878 | }; | ||
| 3879 | var present = [_]bool{ true, true }; | ||
| 3880 | tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; | ||
| 3881 | |||
| 3882 | wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 }); | ||
| 3883 | wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 }); | ||
| 3884 | try std.testing.expect(shared.drag.range() != null); | ||
| 3885 | |||
| 3886 | // The NEIGHBOUR reconnects. That says nothing about a selection over a | ||
| 3887 | // session which never went away, and dropping it would make one tile's | ||
| 3888 | // flaky link erase another tile's selection. | ||
| 3889 | dropDragOver(&tiles[1]); | ||
| 3890 | try std.testing.expect(shared.drag.range() != null); | ||
| 3891 | |||
| 3892 | // The tile under the highlight reconnects: the absolute rows it was | ||
| 3893 | // anchored in are about to mean something else. | ||
| 3894 | dropDragOver(&tiles[0]); | ||
| 3895 | try std.testing.expect(shared.drag.range() == null); | ||
| 3896 | } | ||
| 3897 | |||
| 3898 | test "a relayout drops the highlight, because the stripes move under it" { | ||
| 3899 | const alloc = std.testing.allocator; | ||
| 3900 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3901 | defer std.posix.close(p[0]); | ||
| 3902 | defer std.posix.close(p[1]); | ||
| 3903 | var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; | ||
| 3904 | var tiles = [_]Tile{ | ||
| 3905 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" }, .stripe = .{ .top = 0, .rows = 12 }, .shared = &shared, .idx = 0 }, | ||
| 3906 | .{ .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" }, .stripe = .{ .top = 12, .rows = 12 }, .shared = &shared, .idx = 1 }, | ||
| 3907 | }; | ||
| 3908 | var present = [_]bool{ true, true }; | ||
| 3909 | tiles[0].win = .{ .history_rows = 100, .win_start = 0 }; | ||
| 3910 | |||
| 3911 | wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 3, .at = 0 }); | ||
| 3912 | wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 7, .row = 5, .at = 0 }); | ||
| 3913 | try std.testing.expect(shared.drag.range() != null); | ||
| 3914 | |||
| 3915 | // `x` ends here too, which is why one clear covers both: an anchor | ||
| 3916 | // resolved against the old stripes names a line on a stripe that is | ||
| 3917 | // about to be somewhere else, or gone. | ||
| 3918 | relayout(alloc, &tiles, &present, &shared, 0); | ||
| 3919 | try std.testing.expect(shared.drag.range() == null); | ||
| 3920 | try std.testing.expect(shared.drag.on() == null); | ||
| 3686 | } | 3921 | } |
| 3687 | 3922 | ||
| 3688 | test "a zoom move drops what the wall's input filters are holding" { | 3923 | test "a zoom move drops what the wall's input filters are holding" { |
| @@ -3711,14 +3946,20 @@ test "a zoom move drops what the wall's input filters are holding" { | |||
| 3711 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; | 3946 | var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; |
| 3712 | _ = input.mouse.feed("\x1b[<0;3", &mouse_out); | 3947 | _ = input.mouse.feed("\x1b[<0;3", &mouse_out); |
| 3713 | try std.testing.expect(input.mouse.len > 0); | 3948 | 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 }); | 3949 | tiles[1].win = .{ .history_rows = 200, .win_start = 3 }; |
| 3715 | try std.testing.expect(input.click.down != null); | 3950 | wallMouse(&tiles, &present, &shared, .{ .kind = .press, .button = 0, .col = 4, .row = 15, .at = 0 }); |
| 3951 | wallMouse(&tiles, &present, &shared, .{ .kind = .motion, .button = 32, .col = 9, .row = 17, .at = 0 }); | ||
| 3952 | try std.testing.expect(shared.drag.range() != null); | ||
| 3716 | 3953 | ||
| 3717 | setZoom(&tiles, &shared, &input, 0); | 3954 | setZoom(&tiles, &shared, &input, 0); |
| 3718 | 3955 | ||
| 3719 | try std.testing.expect(!input.prefix.pending); | 3956 | try std.testing.expect(!input.prefix.pending); |
| 3720 | try std.testing.expectEqual(@as(usize, 0), input.mouse.len); | 3957 | try std.testing.expectEqual(@as(usize, 0), input.mouse.len); |
| 3721 | try std.testing.expect(input.click.down == null); | 3958 | // The drag lives in `Shared` so the pumps can paint it, and is dropped |
| 3959 | // by the same reset: its anchor names a stripe that has just stopped | ||
| 3960 | // being on the screen. | ||
| 3961 | try std.testing.expect(shared.drag.range() == null); | ||
| 3962 | try std.testing.expect(shared.drag.on() == null); | ||
| 3722 | } | 3963 | } |
| 3723 | 3964 | ||
| 3724 | test "stripeContentRow: a label bar and the rows outside a stripe name no line" { | 3965 | test "stripeContentRow: a label bar and the rows outside a stripe name no line" { |