b0a68b12
refactor: share terminal selection gestures with native clients
a73x 2026-09-06 07:03
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -97,9 +97,9 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module. | |||
| 97 | |---|---| | 97 | |---|---| |
| 98 | | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | | 98 | | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | |
| 99 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | | 99 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | |
| 100 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | | 100 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `selection` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | |
| 101 | | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` `font_options` `config` `theme` · `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` | | 101 | | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` `font_options` `config` `theme` · `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` | |
| 102 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | | 102 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | |
| 103 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | | 103 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | |
| 104 | | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | | 104 | | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | |
| 105 | | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | | 105 | | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | |
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -1079,3 +1079,26 @@ and queue pre-encoded bytes. Keep history/live snapshot ownership and reply | |||
| 1079 | correlation explicit, share actual semantic helpers instead of copying the TUI | 1079 | correlation explicit, share actual semantic helpers instead of copying the TUI |
| 1080 | interaction core, and inject real SDL wheel events in the retained test adapter. | 1080 | interaction core, and inject real SDL wheel events in the retained test adapter. |
| 1081 | These are implementation constraints, not completed wheel behavior. | 1081 | These are implementation constraints, not completed wheel behavior. |
| 1082 | |||
| 1083 | |||
| 1084 | ## Text selection opening cleanup — 2026-09-06 | ||
| 1085 | |||
| 1086 | The user selected text selection as the next sprint using the updated skill. | ||
| 1087 | Wheel behavior remains unimplemented; its plan is deferred. The reuse map in | ||
| 1088 | `docs/superpowers/plans/2026-09-06-native-text-selection.md` identifies existing | ||
| 1089 | drag state, daemon extraction and shared request correlation before feature edits. | ||
| 1090 | |||
| 1091 | Moved the pure TUI selection module and all its tests to `client.selection`; | ||
| 1092 | both frontends can consume one gesture model. Deleted the old TUI source path | ||
| 1093 | and updated existing callers without changing wall behavior. Luna implemented; | ||
| 1094 | Terra independently found no opening-cleanup defect. The first focused client | ||
| 1095 | test attempt was sandbox-blocked on local sockets; the required escalated | ||
| 1096 | `make check` covers those tests. Logs are in `dist/text-selection/`. | ||
| 1097 | |||
| 1098 | Feature work owns the remaining snapshot/request freshness checks and SDL | ||
| 1099 | clipboard adapter. Review identified a send-time race in addition to UI | ||
| 1100 | cancellation: the pump must compare the displayed sequence when sending and | ||
| 1101 | receiving. The existing protocol does not carry a server sequence precondition; | ||
| 1102 | observed output changes can be rejected, but atomic copying of a historical | ||
| 1103 | frame is not promised. A protocol-level version guard is retained for the next | ||
| 1104 | selection-consistency change if that stronger guarantee is required. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -288,8 +288,9 @@ const mod_table = [_]ModSpec{ | |||
| 288 | // through. It sits beside webhub for the same reason — both are fronts | 288 | // through. It sits beside webhub for the same reason — both are fronts |
| 289 | // on client's Transport and `client.hosts`' grammar; neither imports the | 289 | // on client's Transport and `client.hosts`' grammar; neither imports the |
| 290 | // other; both resolve a spelling through `client.Target.fromSpec`. | 290 | // other; both resolve a spelling through `client.Target.fromSpec`. |
| 291 | // The interaction loop, the painter, the selector and the prediction | 291 | // The interaction loop, painter and prediction overlay are CHILD FILES of |
| 292 | // overlay are CHILD FILES of this root rather than modules of their own, | 292 | // this root rather than modules of their own. Selection policy is a public |
| 293 | // child of the client root so the native and terminal adapters share it, | ||
| 293 | // so nothing outside src/tui/ can name one: a second module claiming any | 294 | // so nothing outside src/tui/ can name one: a second module claiming any |
| 294 | // of those files is a file-in-multiple-modules compile error. `term` is | 295 | // of those files is a file-in-multiple-modules compile error. `term` is |
| 295 | // the children's as much as the root's: `term.replica` is the keyboard | 296 | // the children's as much as the root's: `term.replica` is the keyboard |
docs/superpowers/plans/2026-09-06-native-text-selection.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,81 @@ | |||
| 1 | # Native parity — text selection | ||
| 2 | |||
| 3 | Status: planning and opening cleanup. User selected text selection as the next | ||
| 4 | sprint on 2026-09-06; wheel scrolling remains unimplemented and deferred. | ||
| 5 | Worktree branch: `gui-text-selection` (existing worktree directory retained). | ||
| 6 | |||
| 7 | ## Goal and trial behavior | ||
| 8 | |||
| 9 | Drag across visible terminal text, see the selected cells, and copy the daemon's | ||
| 10 | text to the desktop clipboard on release. Reuse the TUI's single-click versus | ||
| 11 | drag semantics and daemon extraction. A plain click continues to focus a pane. | ||
| 12 | The selection belongs to its starting pane; crossing another pane must never | ||
| 13 | select or copy that pane's text. A new press clears the previous highlight. | ||
| 14 | |||
| 15 | This slice covers visible text and copy on release. Wheel/history navigation, | ||
| 16 | edge autoscroll, double/triple click, rectangular selection, clipboard paste, | ||
| 17 | application mouse forwarding and ligatures remain separate work. The existing | ||
| 18 | GUI does not forward application mouse clicks; this slice preserves that scope. | ||
| 19 | |||
| 20 | ## Reuse and ownership map | ||
| 21 | |||
| 22 | | Behavior or rule | Existing implementation and callers | Intended owner | Reuse, refactor or add; what can be deleted | | ||
| 23 | | --- | --- | --- | --- | | ||
| 24 | | Click/drag distinction, direction, pane confinement, inclusive row spans | `src/tui/select.zig`: `Drag`, `Range.span`; `interact.zig` and `wallview.zig` callers | Shared `client.selection` | Move the pure module and its tests to `src/client/selection.zig`; delete the TUI source and update imports. No second drag state machine. | | ||
| 25 | | Text extraction, soft wraps, hard newlines, UTF-8/graphemes and wide cells | `Engine.extractSelection`, `Server.onSelectionReq`, protocol `SelectionReq`/`SelectionReply` | Daemon engine and existing wire | Reuse unchanged. Never reconstruct copied text from native grid cells. | | ||
| 26 | | Request ID correlation and reply validation | `ClientCore.beginSelection`/`receiveSelectionReply`; TUI `Core.requestSelection`/`selectionCopy` | Shared client core and session pump | Reuse core matching. Add bounded mailbox request/result ownership to `session_pump`; release borrowed frame text before the frame is freed by copying or transferring into owned storage. Do not import TUI `Core`. | | ||
| 27 | | Pane identity, lifetime and frozen displayed grid | `runtime.Live.capture`, `Runtime.accepts`, `workspace.Attachment` | Native runtime | Expose selection-relevant snapshot metadata alongside the grid through the existing boundary; associate requests with attachment and displayed coordinates. Avoid new unlocked reads of pump internals. | | ||
| 28 | | Pointer hit testing, modal routing and cancellation | `interaction.Controller.pointerDown`/`pointerMove`, `focusLost`, `updateGeometry` | Window-free native controller | Use shared drag state with native geometry; add release/copy intent and cancellation. Keep divider drag distinct from text drag. | | ||
| 29 | | Logical-to-physical input, mouse capture, clipboard IO | `frame.Events`, `physicalPoint`, `physicalSignedAxis`, existing pointer hooks | SDL frame adapter | Extend ordinary mouse routing and use SDL clipboard writing. SDL remains confined to frame. Test hooks observe actual clipboard state and inject existing SDL events. | | ||
| 30 | | Selection highlight | `quads.rowInstances`, existing themed cell colors | Native painter | Consume a supplied row span; select complete wide glyph cells and retain readable colors. Never mutate the authoritative grid for highlighting. | | ||
| 31 | |||
| 32 | The existing pure selection module already supplies the needed domain rule; a | ||
| 33 | new selection framework is unnecessary. Moving it to the shared client owner | ||
| 34 | allows both frontends to consume it without importing either window or terminal | ||
| 35 | UI libraries. The TUI's OSC 52 size cap is an adapter limit and must not become | ||
| 36 | the desktop clipboard's cap; the existing wire's 1 MiB bound still applies. | ||
| 37 | |||
| 38 | ## Observable acceptance | ||
| 39 | |||
| 40 | - Three panes across two real daemons, including off-origin panes: forward and | ||
| 41 | reverse drags highlight and copy only the starting pane. Headers, dividers, | ||
| 42 | blank padding and modal dialogs cannot start a terminal selection. | ||
| 43 | - A press/release in one cell changes focus without changing clipboard text. | ||
| 44 | Copy on release uses exact daemon text for soft wraps, hard newlines, trailing | ||
| 45 | spaces, non-ASCII text and wide-cell continuations. A selected wide glyph is | ||
| 46 | visibly complete. Other panes retain their text, PTYs and input behavior. | ||
| 47 | - A new selection, cancellation, resize/DPI transition, focus loss, attachment | ||
| 48 | replacement/reconnect or detach cannot allow a delayed reply to overwrite the | ||
| 49 | clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior | ||
| 50 | contents intact; errors that need action get a bounded notice. | ||
| 51 | - Coordinate validity while output changes is explicit and tested. Favor | ||
| 52 | cancellation when the displayed coordinates no longer identify the selected | ||
| 53 | text, rather than copying newly arrived content under an old highlight. | ||
| 54 | - The painter highlights without changing replica cells. Core policy tests run | ||
| 55 | without GUI libraries. Real clipboard reads and actual framebuffer samples | ||
| 56 | are the independent integration oracles. | ||
| 57 | - Real NVIDIA Wayland drag/release at 200%, retained scale transitions, and | ||
| 58 | separate raw-output responsiveness gates. Linux evidence does not imply Mac | ||
| 59 | acceptance. | ||
| 60 | |||
| 61 | ## Delivery and retained actions | ||
| 62 | |||
| 63 | Luna owns opening shared-selection extraction and feature implementation; | ||
| 64 | Terra independently reviews correctness and structure. Root owns planning, | ||
| 65 | integration oracles, final gates, demonstration, documentation and commits. | ||
| 66 | Opening cleanup is reviewed, checked and committed before feature edits. | ||
| 67 | |||
| 68 | Follow `docs/native-sprint-workflow.md`, serializing builds against the source | ||
| 69 | ban probes in `make check`. Retain logs for full CI, native core/build/units, | ||
| 70 | native integration, NVIDIA scale and stress. Review production growth and remove | ||
| 71 | superseded paths before freezing source. Existing NVIDIA frame-time misses and | ||
| 72 | offscreen growth artifacts remain unresolved renderer follow-ups. | ||
| 73 | |||
| 74 | Assess the existing passive frame hooks as they are touched; add only observation | ||
| 75 | needed for clipboard evidence. Any broader hook extraction requires a concrete | ||
| 76 | benefit rather than an arbitrary file split. Reuse the existing Rig and recorder | ||
| 77 | assets with isolated XDG state and compositor addresses. | ||
| 78 | |||
| 79 | Deliver a private Tailscale review page with achievements, actual GUI recording, | ||
| 80 | controls, checks and limitations. Preserve previous pages. Record demo approval | ||
| 81 | only after the user gives it. | ||
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,8 @@ | |||
| 1 | # Native parity — wheel scrolling | 1 | # Native parity — wheel scrolling |
| 2 | 2 | ||
| 3 | Status: opening architecture cleanup implemented, reviewed and validated; | 3 | Status: opening architecture cleanup implemented, reviewed and validated; |
| 4 | wheel behavior has not been implemented. The user authorized a fresh worktree to begin closing GUI/TUI gaps. | 4 | wheel behavior has not been implemented. On 2026-09-06 the user selected text |
| 5 | selection as the next sprint; this wheel plan is deferred. The user authorized a fresh worktree to begin closing GUI/TUI gaps. | ||
| 5 | This is the first functional slice of git-collab issue `8b16e26b`. | 6 | This is the first functional slice of git-collab issue `8b16e26b`. |
| 6 | 7 | ||
| 7 | ## Sprint goal | 8 | ## Sprint goal |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -34,6 +34,7 @@ pub const layoutfile = @import("layoutfile.zig"); | |||
| 34 | pub const keymap = @import("keymap.zig"); | 34 | pub const keymap = @import("keymap.zig"); |
| 35 | pub const askpass = @import("askpass.zig"); | 35 | pub const askpass = @import("askpass.zig"); |
| 36 | pub const core = @import("client_core.zig"); | 36 | pub const core = @import("client_core.zig"); |
| 37 | pub const selection = @import("selection.zig"); | ||
| 37 | pub const discovery = @import("discovery.zig"); | 38 | pub const discovery = @import("discovery.zig"); |
| 38 | pub const resolver = @import("resolver.zig"); | 39 | pub const resolver = @import("resolver.zig"); |
| 39 | const open_wait = @import("open_wait.zig"); | 40 | const open_wait = @import("open_wait.zig"); |
src/client/selection.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,350 @@ | |||
| 1 | //! The drag behind text selection, as pure state: press, motion, release, and | ||
| 2 | //! the span of columns highlighted on one line of one session. | ||
| 3 | //! | ||
| 4 | //! No tty, no transport, no engine, no allocation beyond its own struct — a | ||
| 5 | //! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of | ||
| 6 | //! those means, which is what lets two drivers in different layers share one | ||
| 7 | //! meaning. Resolving a terminal row to a session line stays with the layout. | ||
| 8 | //! | ||
| 9 | //! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag | ||
| 10 | //! held while the session scrolls must keep naming the lines it started over, | ||
| 11 | //! and a terminal row renames itself the moment output moves the window. | ||
| 12 | const std = @import("std"); | ||
| 13 | |||
| 14 | /// One place in one session, as a driver has resolved a report. | ||
| 15 | pub const Hit = struct { | ||
| 16 | tile: usize, | ||
| 17 | /// Absolute row: counted from the oldest row the daemon still retains. | ||
| 18 | row: u32, | ||
| 19 | /// Grid column, zero-based. Terminal column is the same number — | ||
| 20 | /// every painter emits from column 1 with no x-offset. | ||
| 21 | col: u16, | ||
| 22 | }; | ||
| 23 | |||
| 24 | /// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER | ||
| 25 | /// is, not what is under it. The two part company when the session scrolls, and | ||
| 26 | /// telling a click from a drag is the pointer's question. | ||
| 27 | pub const Cell = struct { row: u16, col: u16 }; | ||
| 28 | |||
| 29 | /// Inclusive columns, the same convention `engine.extractSelection` and | ||
| 30 | /// ghostty's `Selection` use at the other end of the wire. | ||
| 31 | pub const Span = struct { from: u16, to: u16 }; | ||
| 32 | |||
| 33 | /// A completed selection, normalized: `from` is at or before `to` in | ||
| 34 | /// reading order, whichever way the hand moved. | ||
| 35 | pub const Range = struct { | ||
| 36 | from: Hit, | ||
| 37 | to: Hit, | ||
| 38 | |||
| 39 | /// On `Range` rather than `Drag` because two get compared: a drag that moved | ||
| 40 | /// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the | ||
| 41 | /// clamp is not decoration — a grid narrower than its terminal has columns a | ||
| 42 | /// pointer can reach with no cell behind them. | ||
| 43 | pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span { | ||
| 44 | if (self.from.tile != tile) return null; | ||
| 45 | if (row < self.from.row or row > self.to.row) return null; | ||
| 46 | const from = if (row == self.from.row) self.from.col else 0; | ||
| 47 | const to = @min(if (row == self.to.row) self.to.col else cols -| 1, cols -| 1); | ||
| 48 | // A selection whose whole width fell off the grid's right edge | ||
| 49 | // highlights nothing on this row, rather than one clamped cell at | ||
| 50 | // the edge that nobody pointed at. | ||
| 51 | if (from > to) return null; | ||
| 52 | return .{ .from = from, .to = to }; | ||
| 53 | } | ||
| 54 | }; | ||
| 55 | |||
| 56 | /// What a button coming up meant. | ||
| 57 | pub const Release = union(enum) { | ||
| 58 | /// Nothing was down, or the press landed on nothing selectable. | ||
| 59 | nothing, | ||
| 60 | /// Press and release in one cell. Not a selection — tmux's | ||
| 61 | /// `MouseDown1Pane`, which a driver answers by moving its own | ||
| 62 | /// selection to the thing clicked. | ||
| 63 | click: Hit, | ||
| 64 | /// A drag that ended. The highlight STANDS after this: it is what a | ||
| 65 | /// copy is taken from, and what the next press or `clear` drops. | ||
| 66 | selection: Range, | ||
| 67 | }; | ||
| 68 | |||
| 69 | /// One button's worth of drag. Four states, because a press is not yet a | ||
| 70 | /// selection and a release is not the end of one: `.down` may still turn out to | ||
| 71 | /// be a click, and `.held` is a finished selection still on screen. | ||
| 72 | pub const Drag = struct { | ||
| 73 | const Phase = enum { idle, down, dragging, held }; | ||
| 74 | |||
| 75 | phase: Phase = .idle, | ||
| 76 | /// The cell the press was on, which is what a later motion is | ||
| 77 | /// compared against to tell a drag from a tremor. | ||
| 78 | at: Cell = .{ .row = 0, .col = 0 }, | ||
| 79 | anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 80 | active: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 81 | |||
| 82 | /// Whatever was held is dropped however the press turns out: a new | ||
| 83 | /// press is a new selection, and a press on nothing (a label bar) is | ||
| 84 | /// the user putting the old one away. | ||
| 85 | pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 86 | const h = hit orelse { | ||
| 87 | self.* = .{}; | ||
| 88 | return; | ||
| 89 | }; | ||
| 90 | self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h }; | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling | ||
| 94 | /// inside one cell still points at one line. Once it IS a drag it stays one. | ||
| 95 | /// Confined to its starting tile, or it would ask the wrong session. | ||
| 96 | pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 97 | switch (self.phase) { | ||
| 98 | .idle, .held => return, | ||
| 99 | .down => { | ||
| 100 | if (cell.row == self.at.row and cell.col == self.at.col) return; | ||
| 101 | self.phase = .dragging; | ||
| 102 | }, | ||
| 103 | .dragging => {}, | ||
| 104 | } | ||
| 105 | const h = hit orelse return; | ||
| 106 | if (h.tile != self.anchor.tile) return; | ||
| 107 | self.active = h; | ||
| 108 | } | ||
| 109 | |||
| 110 | /// The button came up. See `Release` for what the three answers mean. | ||
| 111 | pub fn release(self: *Drag) Release { | ||
| 112 | switch (self.phase) { | ||
| 113 | .idle, .held => return .nothing, | ||
| 114 | .down => { | ||
| 115 | const hit = self.anchor; | ||
| 116 | self.* = .{}; | ||
| 117 | return .{ .click = hit }; | ||
| 118 | }, | ||
| 119 | .dragging => { | ||
| 120 | self.phase = .held; | ||
| 121 | return .{ .selection = self.rangeLocked() }; | ||
| 122 | }, | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | /// Coordinates stopped meaning what they meant. | ||
| 127 | pub fn clear(self: *Drag) void { | ||
| 128 | self.* = .{}; | ||
| 129 | } | ||
| 130 | |||
| 131 | /// A press that has not moved yet counts; `range`, by contrast, | ||
| 132 | /// answers only about what is on screen. | ||
| 133 | pub fn on(self: *const Drag) ?usize { | ||
| 134 | return if (self.phase == .idle) null else self.anchor.tile; | ||
| 135 | } | ||
| 136 | |||
| 137 | /// The selection as an ordered pair, or null while there is none. | ||
| 138 | pub fn range(self: *const Drag) ?Range { | ||
| 139 | return switch (self.phase) { | ||
| 140 | .idle, .down => null, | ||
| 141 | .dragging, .held => self.rangeLocked(), | ||
| 142 | }; | ||
| 143 | } | ||
| 144 | |||
| 145 | fn rangeLocked(self: *const Drag) Range { | ||
| 146 | const a = self.anchor; | ||
| 147 | const b = self.active; | ||
| 148 | const forward = b.row > a.row or (b.row == a.row and b.col >= a.col); | ||
| 149 | return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a }; | ||
| 150 | } | ||
| 151 | |||
| 152 | /// `Range.span` for the live selection, or null while there is none. | ||
| 153 | pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span { | ||
| 154 | const r = self.range() orelse return null; | ||
| 155 | return r.span(tile, row, cols); | ||
| 156 | } | ||
| 157 | }; | ||
| 158 | |||
| 159 | test "select: a press alone is a click, and highlights nothing on its way" { | ||
| 160 | var d: Drag = .{}; | ||
| 161 | const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 }; | ||
| 162 | d.press(.{ .row = 12, .col = 7 }, hit); | ||
| 163 | // Nothing is highlighted while the button is merely down: a press that | ||
| 164 | // turns out to be a click must never flicker an inversion on its way. | ||
| 165 | try std.testing.expect(d.span(1, 40, 80) == null); | ||
| 166 | try std.testing.expect(d.range() == null); | ||
| 167 | const r = d.release(); | ||
| 168 | try std.testing.expectEqual(@as(usize, 1), r.click.tile); | ||
| 169 | try std.testing.expectEqual(@as(u32, 40), r.click.row); | ||
| 170 | try std.testing.expectEqual(@as(u16, 7), r.click.col); | ||
| 171 | // ...and the click leaves nothing behind to paint. | ||
| 172 | try std.testing.expect(d.range() == null); | ||
| 173 | } | ||
| 174 | |||
| 175 | test "select: a drag names its tile from the press, before it is a selection" { | ||
| 176 | var d: Drag = .{}; | ||
| 177 | try std.testing.expect(d.on() == null); | ||
| 178 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 }); | ||
| 179 | // Pressed, not yet dragged: nothing to paint, and still a tile whose | ||
| 180 | // coordinates a caller may have to drop. | ||
| 181 | try std.testing.expect(d.range() == null); | ||
| 182 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 183 | d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 }); | ||
| 184 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 185 | _ = d.release(); | ||
| 186 | // Still held after the button came up, which is what a highlight is. | ||
| 187 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 188 | d.clear(); | ||
| 189 | try std.testing.expect(d.on() == null); | ||
| 190 | } | ||
| 191 | |||
| 192 | test "select: a press on nothing selectable is nothing at all" { | ||
| 193 | var d: Drag = .{}; | ||
| 194 | d.press(.{ .row = 0, .col = 3 }, null); | ||
| 195 | d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 }); | ||
| 196 | try std.testing.expect(d.range() == null); | ||
| 197 | try std.testing.expect(d.release() == .nothing); | ||
| 198 | } | ||
| 199 | |||
| 200 | test "select: either drag direction yields the same ordered pair" { | ||
| 201 | const top: Hit = .{ .tile = 0, .row = 100, .col = 4 }; | ||
| 202 | const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 }; | ||
| 203 | |||
| 204 | var down: Drag = .{}; | ||
| 205 | down.press(.{ .row = 2, .col = 4 }, top); | ||
| 206 | down.motion(.{ .row = 5, .col = 12 }, bot); | ||
| 207 | const a = down.release().selection; | ||
| 208 | |||
| 209 | var up: Drag = .{}; | ||
| 210 | up.press(.{ .row = 5, .col = 12 }, bot); | ||
| 211 | up.motion(.{ .row = 2, .col = 4 }, top); | ||
| 212 | const b = up.release().selection; | ||
| 213 | |||
| 214 | try std.testing.expectEqual(a.from, b.from); | ||
| 215 | try std.testing.expectEqual(a.to, b.to); | ||
| 216 | try std.testing.expectEqual(@as(u32, 100), a.from.row); | ||
| 217 | try std.testing.expectEqual(@as(u16, 4), a.from.col); | ||
| 218 | try std.testing.expectEqual(@as(u32, 103), a.to.row); | ||
| 219 | try std.testing.expectEqual(@as(u16, 12), a.to.col); | ||
| 220 | } | ||
| 221 | |||
| 222 | test "select: a one-row drag reads left to right whichever way the hand moved" { | ||
| 223 | var d: Drag = .{}; | ||
| 224 | d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 }); | ||
| 225 | d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 }); | ||
| 226 | const s = d.span(0, 50, 80).?; | ||
| 227 | try std.testing.expectEqual(@as(u16, 2), s.from); | ||
| 228 | try std.testing.expectEqual(@as(u16, 9), s.to); | ||
| 229 | // One row, so neither neighbour is in it. | ||
| 230 | try std.testing.expect(d.span(0, 49, 80) == null); | ||
| 231 | try std.testing.expect(d.span(0, 51, 80) == null); | ||
| 232 | } | ||
| 233 | |||
| 234 | test "select: the ends are clipped at the anchors, and the middle is the full width" { | ||
| 235 | var d: Drag = .{}; | ||
| 236 | d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 }); | ||
| 237 | d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 }); | ||
| 238 | |||
| 239 | const first = d.span(2, 7, 80).?; | ||
| 240 | try std.testing.expectEqual(@as(u16, 30), first.from); | ||
| 241 | try std.testing.expectEqual(@as(u16, 79), first.to); | ||
| 242 | const middle = d.span(2, 8, 80).?; | ||
| 243 | try std.testing.expectEqual(@as(u16, 0), middle.from); | ||
| 244 | try std.testing.expectEqual(@as(u16, 79), middle.to); | ||
| 245 | const last = d.span(2, 10, 80).?; | ||
| 246 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 247 | try std.testing.expectEqual(@as(u16, 6), last.to); | ||
| 248 | // Outside the range on both sides. | ||
| 249 | try std.testing.expect(d.span(2, 6, 80) == null); | ||
| 250 | try std.testing.expect(d.span(2, 11, 80) == null); | ||
| 251 | } | ||
| 252 | |||
| 253 | test "select: the highlight is one tile's, and the drag cannot leave it" { | ||
| 254 | var d: Drag = .{}; | ||
| 255 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 }); | ||
| 256 | // A drag onto the neighbouring stripe: it IS a drag, and the active | ||
| 257 | // end stays on the last line of the tile it started in. | ||
| 258 | d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 }); | ||
| 259 | const s = d.span(0, 5, 80).?; | ||
| 260 | try std.testing.expectEqual(@as(u16, 1), s.from); | ||
| 261 | try std.testing.expectEqual(@as(u16, 1), s.to); | ||
| 262 | // Neither the row it strayed onto nor the tile it strayed into. | ||
| 263 | try std.testing.expect(d.span(1, 200, 80) == null); | ||
| 264 | try std.testing.expect(d.span(0, 200, 80) == null); | ||
| 265 | // The same rows, asked for as somebody else's tile, are not the answer. | ||
| 266 | try std.testing.expect(d.span(1, 5, 80) == null); | ||
| 267 | } | ||
| 268 | |||
| 269 | test "select: a pointer that never leaves the press cell has not dragged" { | ||
| 270 | var d: Drag = .{}; | ||
| 271 | d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 272 | // `?1002h` reports on a cell change, but a terminal repeating the cell | ||
| 273 | // must not turn a click into an empty selection. | ||
| 274 | d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 275 | try std.testing.expect(d.range() == null); | ||
| 276 | try std.testing.expect(d.release() == .click); | ||
| 277 | } | ||
| 278 | |||
| 279 | test "select: a drag that comes back to where it started is still a drag" { | ||
| 280 | var d: Drag = .{}; | ||
| 281 | const home: Hit = .{ .tile = 0, .row = 6, .col = 20 }; | ||
| 282 | d.press(.{ .row = 6, .col = 20 }, home); | ||
| 283 | d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 }); | ||
| 284 | d.motion(.{ .row = 6, .col = 20 }, home); | ||
| 285 | try std.testing.expect(d.release() == .selection); | ||
| 286 | // One cell selected, and it is still on screen after the button is up. | ||
| 287 | const s = d.span(0, 6, 80).?; | ||
| 288 | try std.testing.expectEqual(@as(u16, 20), s.from); | ||
| 289 | try std.testing.expectEqual(@as(u16, 20), s.to); | ||
| 290 | } | ||
| 291 | |||
| 292 | test "select: the highlight outlives the release, and dies on clear" { | ||
| 293 | var d: Drag = .{}; | ||
| 294 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 295 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 296 | _ = d.release(); | ||
| 297 | try std.testing.expect(d.span(0, 3, 80) != null); | ||
| 298 | // A motion after the button is up is somebody else's pointer moving | ||
| 299 | // over a selection that is finished. | ||
| 300 | d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 }); | ||
| 301 | try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row); | ||
| 302 | try std.testing.expect(d.release() == .nothing); | ||
| 303 | d.clear(); | ||
| 304 | try std.testing.expect(d.range() == null); | ||
| 305 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 306 | } | ||
| 307 | |||
| 308 | test "select: a new press drops the selection the last one left" { | ||
| 309 | var d: Drag = .{}; | ||
| 310 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 311 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 312 | _ = d.release(); | ||
| 313 | d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 }); | ||
| 314 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 315 | try std.testing.expect(d.range() == null); | ||
| 316 | // ...and a press on nothing drops it just as thoroughly. | ||
| 317 | d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 }); | ||
| 318 | try std.testing.expect(d.range() != null); | ||
| 319 | d.press(.{ .row = 0, .col = 0 }, null); | ||
| 320 | try std.testing.expect(d.range() == null); | ||
| 321 | } | ||
| 322 | |||
| 323 | test "select: a motion with no button down selects nothing" { | ||
| 324 | var d: Drag = .{}; | ||
| 325 | d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 }); | ||
| 326 | try std.testing.expect(d.range() == null); | ||
| 327 | try std.testing.expect(d.release() == .nothing); | ||
| 328 | } | ||
| 329 | |||
| 330 | test "select: a span past the grid's right edge is clipped, not painted at the edge" { | ||
| 331 | // The tty is wider than the grid — latest-wins leaves that shape | ||
| 332 | // routinely — so a pointer can reach columns with no cell behind them. | ||
| 333 | var d: Drag = .{}; | ||
| 334 | d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 }); | ||
| 335 | d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 }); | ||
| 336 | // The first row's anchor is off the grid entirely: nothing to invert, | ||
| 337 | // and NOT one cell at column 39. | ||
| 338 | try std.testing.expect(d.span(0, 2, 40) == null); | ||
| 339 | // The last row runs from the left edge to the grid's own right edge. | ||
| 340 | const last = d.span(0, 3, 40).?; | ||
| 341 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 342 | try std.testing.expectEqual(@as(u16, 39), last.to); | ||
| 343 | } | ||
| 344 | |||
| 345 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 346 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 347 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 348 | test { | ||
| 349 | std.testing.refAllDeclsRecursive(@This()); | ||
| 350 | } | ||
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -23,13 +23,14 @@ const client_os = @import("client_os"); | |||
| 23 | // `paint`, which a container-level `paint` would collide with. | 23 | // `paint`, which a container-level `paint` would collide with. |
| 24 | const paint_mod = @import("paint.zig"); | 24 | const paint_mod = @import("paint.zig"); |
| 25 | const askpass = @import("client").askpass; | 25 | const askpass = @import("client").askpass; |
| 26 | const select = @import("select.zig"); | 26 | const select = @import("client").selection; |
| 27 | // The command prefix byte. Owned by keymap because the dialler in client.zig | 27 | // The command prefix byte. Owned by keymap because the dialler in client.zig |
| 28 | // watches for it with no session and no terminal in the picture. | 28 | // watches for it with no session and no terminal in the picture. |
| 29 | const detach_key = @import("client").keymap.detach_key; | 29 | const detach_key = @import("client").keymap.detach_key; |
| 30 | 30 | ||
| 31 | /// What `select` answers, in the shape `paint` asks for. The two are siblings | 31 | /// What `select` answers, in the shape `paint` asks for. Selection policy lives |
| 32 | /// that may not import one another, so somebody above both joins them. | 32 | /// in `client.selection`; this adapter converts its absolute rows to the wall's |
| 33 | /// grid rows before painting. | ||
| 33 | /// | 34 | /// |
| 34 | /// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter | 35 | /// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter |
| 35 | /// speaks grid rows, so the history count of the frame being painted turns one | 36 | /// speaks grid rows, so the history count of the frame being painted turns one |
src/tui/paint.zig
| Old | New | ||
|---|---|---|---|
| @@ -18,8 +18,8 @@ pub const sync_end = "\x1b[?25h\x1b[?2026l"; | |||
| 18 | /// Inclusive grid columns of one row, painted inverted. | 18 | /// Inclusive grid columns of one row, painted inverted. |
| 19 | pub const Span = struct { from: u16, to: u16 }; | 19 | pub const Span = struct { from: u16, to: u16 }; |
| 20 | 20 | ||
| 21 | /// A callback, not a shape: `select.zig` is a sibling child file of the same | 21 | /// A callback, not a shape: `client.selection` owns selection state, while |
| 22 | /// module root, and neither file imports the other. | 22 | /// this file adapts its spans into painted output. |
| 23 | pub const Highlight = struct { | 23 | pub const Highlight = struct { |
| 24 | ctx: ?*anyopaque = null, | 24 | ctx: ?*anyopaque = null, |
| 25 | span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, | 25 | span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, |
src/tui/select.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,350 +0,0 @@ | |||
| 1 | //! The drag behind text selection, as pure state: press, motion, release, and | ||
| 2 | //! the span of columns highlighted on one line of one session. | ||
| 3 | //! | ||
| 4 | //! No tty, no transport, no engine, no allocation beyond its own struct — a | ||
| 5 | //! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of | ||
| 6 | //! those means, which is what lets two drivers in different layers share one | ||
| 7 | //! meaning. Resolving a terminal row to a session line stays with the layout. | ||
| 8 | //! | ||
| 9 | //! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag | ||
| 10 | //! held while the session scrolls must keep naming the lines it started over, | ||
| 11 | //! and a terminal row renames itself the moment output moves the window. | ||
| 12 | const std = @import("std"); | ||
| 13 | |||
| 14 | /// One place in one session, as a driver has resolved a report. | ||
| 15 | pub const Hit = struct { | ||
| 16 | tile: usize, | ||
| 17 | /// Absolute row: counted from the oldest row the daemon still retains. | ||
| 18 | row: u32, | ||
| 19 | /// Grid column, zero-based. Terminal column is the same number — | ||
| 20 | /// every painter emits from column 1 with no x-offset. | ||
| 21 | col: u16, | ||
| 22 | }; | ||
| 23 | |||
| 24 | /// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER | ||
| 25 | /// is, not what is under it. The two part company when the session scrolls, and | ||
| 26 | /// telling a click from a drag is the pointer's question. | ||
| 27 | pub const Cell = struct { row: u16, col: u16 }; | ||
| 28 | |||
| 29 | /// Inclusive columns, the same convention `engine.extractSelection` and | ||
| 30 | /// ghostty's `Selection` use at the other end of the wire. | ||
| 31 | pub const Span = struct { from: u16, to: u16 }; | ||
| 32 | |||
| 33 | /// A completed selection, normalized: `from` is at or before `to` in | ||
| 34 | /// reading order, whichever way the hand moved. | ||
| 35 | pub const Range = struct { | ||
| 36 | from: Hit, | ||
| 37 | to: Hit, | ||
| 38 | |||
| 39 | /// On `Range` rather than `Drag` because two get compared: a drag that moved | ||
| 40 | /// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the | ||
| 41 | /// clamp is not decoration — a grid narrower than its terminal has columns a | ||
| 42 | /// pointer can reach with no cell behind them. | ||
| 43 | pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span { | ||
| 44 | if (self.from.tile != tile) return null; | ||
| 45 | if (row < self.from.row or row > self.to.row) return null; | ||
| 46 | const from = if (row == self.from.row) self.from.col else 0; | ||
| 47 | const to = @min(if (row == self.to.row) self.to.col else cols -| 1, cols -| 1); | ||
| 48 | // A selection whose whole width fell off the grid's right edge | ||
| 49 | // highlights nothing on this row, rather than one clamped cell at | ||
| 50 | // the edge that nobody pointed at. | ||
| 51 | if (from > to) return null; | ||
| 52 | return .{ .from = from, .to = to }; | ||
| 53 | } | ||
| 54 | }; | ||
| 55 | |||
| 56 | /// What a button coming up meant. | ||
| 57 | pub const Release = union(enum) { | ||
| 58 | /// Nothing was down, or the press landed on nothing selectable. | ||
| 59 | nothing, | ||
| 60 | /// Press and release in one cell. Not a selection — tmux's | ||
| 61 | /// `MouseDown1Pane`, which a driver answers by moving its own | ||
| 62 | /// selection to the thing clicked. | ||
| 63 | click: Hit, | ||
| 64 | /// A drag that ended. The highlight STANDS after this: it is what a | ||
| 65 | /// copy is taken from, and what the next press or `clear` drops. | ||
| 66 | selection: Range, | ||
| 67 | }; | ||
| 68 | |||
| 69 | /// One button's worth of drag. Four states, because a press is not yet a | ||
| 70 | /// selection and a release is not the end of one: `.down` may still turn out to | ||
| 71 | /// be a click, and `.held` is a finished selection still on screen. | ||
| 72 | pub const Drag = struct { | ||
| 73 | const Phase = enum { idle, down, dragging, held }; | ||
| 74 | |||
| 75 | phase: Phase = .idle, | ||
| 76 | /// The cell the press was on, which is what a later motion is | ||
| 77 | /// compared against to tell a drag from a tremor. | ||
| 78 | at: Cell = .{ .row = 0, .col = 0 }, | ||
| 79 | anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 80 | active: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 81 | |||
| 82 | /// Whatever was held is dropped however the press turns out: a new | ||
| 83 | /// press is a new selection, and a press on nothing (a label bar) is | ||
| 84 | /// the user putting the old one away. | ||
| 85 | pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 86 | const h = hit orelse { | ||
| 87 | self.* = .{}; | ||
| 88 | return; | ||
| 89 | }; | ||
| 90 | self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h }; | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling | ||
| 94 | /// inside one cell still points at one line. Once it IS a drag it stays one. | ||
| 95 | /// Confined to its starting tile, or it would ask the wrong session. | ||
| 96 | pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 97 | switch (self.phase) { | ||
| 98 | .idle, .held => return, | ||
| 99 | .down => { | ||
| 100 | if (cell.row == self.at.row and cell.col == self.at.col) return; | ||
| 101 | self.phase = .dragging; | ||
| 102 | }, | ||
| 103 | .dragging => {}, | ||
| 104 | } | ||
| 105 | const h = hit orelse return; | ||
| 106 | if (h.tile != self.anchor.tile) return; | ||
| 107 | self.active = h; | ||
| 108 | } | ||
| 109 | |||
| 110 | /// The button came up. See `Release` for what the three answers mean. | ||
| 111 | pub fn release(self: *Drag) Release { | ||
| 112 | switch (self.phase) { | ||
| 113 | .idle, .held => return .nothing, | ||
| 114 | .down => { | ||
| 115 | const hit = self.anchor; | ||
| 116 | self.* = .{}; | ||
| 117 | return .{ .click = hit }; | ||
| 118 | }, | ||
| 119 | .dragging => { | ||
| 120 | self.phase = .held; | ||
| 121 | return .{ .selection = self.rangeLocked() }; | ||
| 122 | }, | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | /// Coordinates stopped meaning what they meant. | ||
| 127 | pub fn clear(self: *Drag) void { | ||
| 128 | self.* = .{}; | ||
| 129 | } | ||
| 130 | |||
| 131 | /// A press that has not moved yet counts; `range`, by contrast, | ||
| 132 | /// answers only about what is on screen. | ||
| 133 | pub fn on(self: *const Drag) ?usize { | ||
| 134 | return if (self.phase == .idle) null else self.anchor.tile; | ||
| 135 | } | ||
| 136 | |||
| 137 | /// The selection as an ordered pair, or null while there is none. | ||
| 138 | pub fn range(self: *const Drag) ?Range { | ||
| 139 | return switch (self.phase) { | ||
| 140 | .idle, .down => null, | ||
| 141 | .dragging, .held => self.rangeLocked(), | ||
| 142 | }; | ||
| 143 | } | ||
| 144 | |||
| 145 | fn rangeLocked(self: *const Drag) Range { | ||
| 146 | const a = self.anchor; | ||
| 147 | const b = self.active; | ||
| 148 | const forward = b.row > a.row or (b.row == a.row and b.col >= a.col); | ||
| 149 | return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a }; | ||
| 150 | } | ||
| 151 | |||
| 152 | /// `Range.span` for the live selection, or null while there is none. | ||
| 153 | pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span { | ||
| 154 | const r = self.range() orelse return null; | ||
| 155 | return r.span(tile, row, cols); | ||
| 156 | } | ||
| 157 | }; | ||
| 158 | |||
| 159 | test "select: a press alone is a click, and highlights nothing on its way" { | ||
| 160 | var d: Drag = .{}; | ||
| 161 | const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 }; | ||
| 162 | d.press(.{ .row = 12, .col = 7 }, hit); | ||
| 163 | // Nothing is highlighted while the button is merely down: a press that | ||
| 164 | // turns out to be a click must never flicker an inversion on its way. | ||
| 165 | try std.testing.expect(d.span(1, 40, 80) == null); | ||
| 166 | try std.testing.expect(d.range() == null); | ||
| 167 | const r = d.release(); | ||
| 168 | try std.testing.expectEqual(@as(usize, 1), r.click.tile); | ||
| 169 | try std.testing.expectEqual(@as(u32, 40), r.click.row); | ||
| 170 | try std.testing.expectEqual(@as(u16, 7), r.click.col); | ||
| 171 | // ...and the click leaves nothing behind to paint. | ||
| 172 | try std.testing.expect(d.range() == null); | ||
| 173 | } | ||
| 174 | |||
| 175 | test "select: a drag names its tile from the press, before it is a selection" { | ||
| 176 | var d: Drag = .{}; | ||
| 177 | try std.testing.expect(d.on() == null); | ||
| 178 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 }); | ||
| 179 | // Pressed, not yet dragged: nothing to paint, and still a tile whose | ||
| 180 | // coordinates a caller may have to drop. | ||
| 181 | try std.testing.expect(d.range() == null); | ||
| 182 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 183 | d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 }); | ||
| 184 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 185 | _ = d.release(); | ||
| 186 | // Still held after the button came up, which is what a highlight is. | ||
| 187 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 188 | d.clear(); | ||
| 189 | try std.testing.expect(d.on() == null); | ||
| 190 | } | ||
| 191 | |||
| 192 | test "select: a press on nothing selectable is nothing at all" { | ||
| 193 | var d: Drag = .{}; | ||
| 194 | d.press(.{ .row = 0, .col = 3 }, null); | ||
| 195 | d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 }); | ||
| 196 | try std.testing.expect(d.range() == null); | ||
| 197 | try std.testing.expect(d.release() == .nothing); | ||
| 198 | } | ||
| 199 | |||
| 200 | test "select: either drag direction yields the same ordered pair" { | ||
| 201 | const top: Hit = .{ .tile = 0, .row = 100, .col = 4 }; | ||
| 202 | const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 }; | ||
| 203 | |||
| 204 | var down: Drag = .{}; | ||
| 205 | down.press(.{ .row = 2, .col = 4 }, top); | ||
| 206 | down.motion(.{ .row = 5, .col = 12 }, bot); | ||
| 207 | const a = down.release().selection; | ||
| 208 | |||
| 209 | var up: Drag = .{}; | ||
| 210 | up.press(.{ .row = 5, .col = 12 }, bot); | ||
| 211 | up.motion(.{ .row = 2, .col = 4 }, top); | ||
| 212 | const b = up.release().selection; | ||
| 213 | |||
| 214 | try std.testing.expectEqual(a.from, b.from); | ||
| 215 | try std.testing.expectEqual(a.to, b.to); | ||
| 216 | try std.testing.expectEqual(@as(u32, 100), a.from.row); | ||
| 217 | try std.testing.expectEqual(@as(u16, 4), a.from.col); | ||
| 218 | try std.testing.expectEqual(@as(u32, 103), a.to.row); | ||
| 219 | try std.testing.expectEqual(@as(u16, 12), a.to.col); | ||
| 220 | } | ||
| 221 | |||
| 222 | test "select: a one-row drag reads left to right whichever way the hand moved" { | ||
| 223 | var d: Drag = .{}; | ||
| 224 | d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 }); | ||
| 225 | d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 }); | ||
| 226 | const s = d.span(0, 50, 80).?; | ||
| 227 | try std.testing.expectEqual(@as(u16, 2), s.from); | ||
| 228 | try std.testing.expectEqual(@as(u16, 9), s.to); | ||
| 229 | // One row, so neither neighbour is in it. | ||
| 230 | try std.testing.expect(d.span(0, 49, 80) == null); | ||
| 231 | try std.testing.expect(d.span(0, 51, 80) == null); | ||
| 232 | } | ||
| 233 | |||
| 234 | test "select: the ends are clipped at the anchors, and the middle is the full width" { | ||
| 235 | var d: Drag = .{}; | ||
| 236 | d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 }); | ||
| 237 | d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 }); | ||
| 238 | |||
| 239 | const first = d.span(2, 7, 80).?; | ||
| 240 | try std.testing.expectEqual(@as(u16, 30), first.from); | ||
| 241 | try std.testing.expectEqual(@as(u16, 79), first.to); | ||
| 242 | const middle = d.span(2, 8, 80).?; | ||
| 243 | try std.testing.expectEqual(@as(u16, 0), middle.from); | ||
| 244 | try std.testing.expectEqual(@as(u16, 79), middle.to); | ||
| 245 | const last = d.span(2, 10, 80).?; | ||
| 246 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 247 | try std.testing.expectEqual(@as(u16, 6), last.to); | ||
| 248 | // Outside the range on both sides. | ||
| 249 | try std.testing.expect(d.span(2, 6, 80) == null); | ||
| 250 | try std.testing.expect(d.span(2, 11, 80) == null); | ||
| 251 | } | ||
| 252 | |||
| 253 | test "select: the highlight is one tile's, and the drag cannot leave it" { | ||
| 254 | var d: Drag = .{}; | ||
| 255 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 }); | ||
| 256 | // A drag onto the neighbouring stripe: it IS a drag, and the active | ||
| 257 | // end stays on the last line of the tile it started in. | ||
| 258 | d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 }); | ||
| 259 | const s = d.span(0, 5, 80).?; | ||
| 260 | try std.testing.expectEqual(@as(u16, 1), s.from); | ||
| 261 | try std.testing.expectEqual(@as(u16, 1), s.to); | ||
| 262 | // Neither the row it strayed onto nor the tile it strayed into. | ||
| 263 | try std.testing.expect(d.span(1, 200, 80) == null); | ||
| 264 | try std.testing.expect(d.span(0, 200, 80) == null); | ||
| 265 | // The same rows, asked for as somebody else's tile, are not the answer. | ||
| 266 | try std.testing.expect(d.span(1, 5, 80) == null); | ||
| 267 | } | ||
| 268 | |||
| 269 | test "select: a pointer that never leaves the press cell has not dragged" { | ||
| 270 | var d: Drag = .{}; | ||
| 271 | d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 272 | // `?1002h` reports on a cell change, but a terminal repeating the cell | ||
| 273 | // must not turn a click into an empty selection. | ||
| 274 | d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 275 | try std.testing.expect(d.range() == null); | ||
| 276 | try std.testing.expect(d.release() == .click); | ||
| 277 | } | ||
| 278 | |||
| 279 | test "select: a drag that comes back to where it started is still a drag" { | ||
| 280 | var d: Drag = .{}; | ||
| 281 | const home: Hit = .{ .tile = 0, .row = 6, .col = 20 }; | ||
| 282 | d.press(.{ .row = 6, .col = 20 }, home); | ||
| 283 | d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 }); | ||
| 284 | d.motion(.{ .row = 6, .col = 20 }, home); | ||
| 285 | try std.testing.expect(d.release() == .selection); | ||
| 286 | // One cell selected, and it is still on screen after the button is up. | ||
| 287 | const s = d.span(0, 6, 80).?; | ||
| 288 | try std.testing.expectEqual(@as(u16, 20), s.from); | ||
| 289 | try std.testing.expectEqual(@as(u16, 20), s.to); | ||
| 290 | } | ||
| 291 | |||
| 292 | test "select: the highlight outlives the release, and dies on clear" { | ||
| 293 | var d: Drag = .{}; | ||
| 294 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 295 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 296 | _ = d.release(); | ||
| 297 | try std.testing.expect(d.span(0, 3, 80) != null); | ||
| 298 | // A motion after the button is up is somebody else's pointer moving | ||
| 299 | // over a selection that is finished. | ||
| 300 | d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 }); | ||
| 301 | try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row); | ||
| 302 | try std.testing.expect(d.release() == .nothing); | ||
| 303 | d.clear(); | ||
| 304 | try std.testing.expect(d.range() == null); | ||
| 305 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 306 | } | ||
| 307 | |||
| 308 | test "select: a new press drops the selection the last one left" { | ||
| 309 | var d: Drag = .{}; | ||
| 310 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 311 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 312 | _ = d.release(); | ||
| 313 | d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 }); | ||
| 314 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 315 | try std.testing.expect(d.range() == null); | ||
| 316 | // ...and a press on nothing drops it just as thoroughly. | ||
| 317 | d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 }); | ||
| 318 | try std.testing.expect(d.range() != null); | ||
| 319 | d.press(.{ .row = 0, .col = 0 }, null); | ||
| 320 | try std.testing.expect(d.range() == null); | ||
| 321 | } | ||
| 322 | |||
| 323 | test "select: a motion with no button down selects nothing" { | ||
| 324 | var d: Drag = .{}; | ||
| 325 | d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 }); | ||
| 326 | try std.testing.expect(d.range() == null); | ||
| 327 | try std.testing.expect(d.release() == .nothing); | ||
| 328 | } | ||
| 329 | |||
| 330 | test "select: a span past the grid's right edge is clipped, not painted at the edge" { | ||
| 331 | // The tty is wider than the grid — latest-wins leaves that shape | ||
| 332 | // routinely — so a pointer can reach columns with no cell behind them. | ||
| 333 | var d: Drag = .{}; | ||
| 334 | d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 }); | ||
| 335 | d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 }); | ||
| 336 | // The first row's anchor is off the grid entirely: nothing to invert, | ||
| 337 | // and NOT one cell at column 39. | ||
| 338 | try std.testing.expect(d.span(0, 2, 40) == null); | ||
| 339 | // The last row runs from the left edge to the grid's own right edge. | ||
| 340 | const last = d.span(0, 3, 40).?; | ||
| 341 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 342 | try std.testing.expectEqual(@as(u16, 39), last.to); | ||
| 343 | } | ||
| 344 | |||
| 345 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 346 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 347 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 348 | test { | ||
| 349 | std.testing.refAllDeclsRecursive(@This()); | ||
| 350 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,7 @@ const spawn = @import("spawn"); | |||
| 12 | const proxy = @import("proxy"); | 12 | const proxy = @import("proxy"); |
| 13 | const grid_mod = @import("term").grid; | 13 | const grid_mod = @import("term").grid; |
| 14 | const paint = @import("paint.zig"); | 14 | const paint = @import("paint.zig"); |
| 15 | const select = @import("select.zig"); | 15 | const select = client.selection; |
| 16 | // Counters ride out through `Shared` because a detached pump never reaches | 16 | // Counters ride out through `Shared` because a detached pump never reaches |
| 17 | // a `Core.deinit`. | 17 | // a `Core.deinit`. |
| 18 | // The chord table and the prediction hooks a focused tile shares with the | 18 | // The chord table and the prediction hooks a focused tile shares with the |
| @@ -2643,7 +2643,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2643 | test { | 2643 | test { |
| 2644 | _ = @import("interact.zig"); | 2644 | _ = @import("interact.zig"); |
| 2645 | _ = @import("paint.zig"); | 2645 | _ = @import("paint.zig"); |
| 2646 | _ = @import("select.zig"); | ||
| 2647 | _ = @import("predict.zig"); | 2646 | _ = @import("predict.zig"); |
| 2648 | _ = @import("wall_test_harness.zig"); | 2647 | _ = @import("wall_test_harness.zig"); |
| 2649 | _ = @import("wall_test_host.zig"); | 2648 | _ = @import("wall_test_host.zig"); |