fix(copy): preserve pointer lifecycle across search
- commit
- aaa33191c9a30d29aede81e2d6ed30ee949ef06c
- author
- brice · · 2026-07-15 09:42 UTC
- committer
- brice · · 2026-07-15 09:45 UTC
- parent
- 5173b95
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +371 -283
A internal/usecase/daemon/copy_mouse_input.go +305 -0
| @@ -0,0 +1,305 @@ | |||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 2 | + | package daemon | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // handleCopyMouse owns copy-mode mouse routing. A release is lifecycle input | |
| 10 | + | // even while visual search owns all other copy-mode mouse input. | |
| 11 | + | func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) bool { | |
| 12 | + | rt := ac.overlays | |
| 13 | + | if !rt.copyActive() { | |
| 14 | + | return false | |
| 15 | + | } | |
| 16 | + | if ev.Button == mouse.Left && ev.Type == mouse.Release { | |
| 17 | + | rt.copyMu.Lock() | |
| 18 | + | rt.invalidateCopyPointerLocked(false) | |
| 19 | + | rt.copyMu.Unlock() | |
| 20 | + | return true | |
| 21 | + | } | |
| 22 | + | if rt.copySearchActive() { | |
| 23 | + | return true | |
| 24 | + | } | |
| 25 | + | switch ev.Button { | |
| 26 | + | case mouse.WheelUp: | |
| 27 | + | d.copyWheel(sess, ac, -3) | |
| 28 | + | case mouse.WheelDown: | |
| 29 | + | d.copyWheel(sess, ac, 3) | |
| 30 | + | case mouse.Left: | |
| 31 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 32 | + | } | |
| 33 | + | return true | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // invalidateRejectedLeftPointer clears a stale drag before returning from an | |
| 37 | + | // event that cannot be routed to terminal content. It never runs while tab or | |
| 38 | + | // pane locks are held, preserving the copyMu -> tab/pane lock prohibition. | |
| 39 | + | func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |
| 40 | + | if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | rt.copyMu.Lock() | |
| 44 | + | rt.invalidateCopyPointerLocked(true) | |
| 45 | + | rt.copyMu.Unlock() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // handleFreshCopyPress is the only normal-screen press path that creates a | |
| 49 | + | // selection candidate. Its geometry comes from the same frame-absolute hit | |
| 50 | + | // test used by active copy mode, never from pane-local synthetic rectangles. | |
| 51 | + | func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |
| 52 | + | if routed == nil { | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | tb.mu.Lock() | |
| 56 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |
| 57 | + | tb.mu.Unlock() | |
| 58 | + | if !ok || geometry.pane != routed { | |
| 59 | + | ac.overlays.copyMu.Lock() | |
| 60 | + | ac.overlays.invalidateCopyPointerLocked(true) | |
| 61 | + | ac.overlays.copyMu.Unlock() | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | geometry.pane.mu.Lock() | |
| 65 | + | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 66 | + | altScreen := geometry.pane.screen.AltScreenActive() | |
| 67 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |
| 68 | + | geometry.pane.mu.Unlock() | |
| 69 | + | if mouseMode != 0 || altScreen { | |
| 70 | + | return false // child forwarding retains its existing raw-byte path. | |
| 71 | + | } | |
| 72 | + | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |
| 73 | + | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |
| 74 | + | if !ok { | |
| 75 | + | ac.overlays.copyMu.Lock() | |
| 76 | + | ac.overlays.invalidateCopyPointerLocked(true) | |
| 77 | + | ac.overlays.copyMu.Unlock() | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | rt := ac.overlays | |
| 81 | + | rt.copyMu.Lock() | |
| 82 | + | if rt.copyMode != nil || rt.copyCandidate != nil { | |
| 83 | + | rt.copyMu.Unlock() | |
| 84 | + | return true | |
| 85 | + | } | |
| 86 | + | rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |
| 87 | + | pointer := rt.copyPointer | |
| 88 | + | now := d.clock.Now() | |
| 89 | + | doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now) | |
| 90 | + | if doubleClick { | |
| 91 | + | rt.copyClick = copyClickCandidate{} | |
| 92 | + | } else { | |
| 93 | + | rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now} | |
| 94 | + | } | |
| 95 | + | rt.copyMu.Unlock() | |
| 96 | + | if !doubleClick { | |
| 97 | + | return true | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | selectedWord := false | |
| 101 | + | if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) { | |
| 102 | + | mode.SetPosition(mapped.pos) | |
| 103 | + | selectedWord = mode.SelectWordAt(mapped.pos) | |
| 104 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 105 | + | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | pointer.wordDrag = selectedWord | |
| 109 | + | runtime.copyPointer = pointer | |
| 110 | + | }) { | |
| 111 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 112 | + | } | |
| 113 | + | return true | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // handleFreshCopyPointer maps a pre-copy drag against its press-owned document | |
| 117 | + | // and geometry. It is intentionally independent of the currently hovered pane. | |
| 118 | + | func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool { | |
| 119 | + | rt := ac.overlays | |
| 120 | + | rt.copyMu.Lock() | |
| 121 | + | pointer := rt.copyPointer | |
| 122 | + | epoch := rt.copyPointerEpoch | |
| 123 | + | active := rt.copyMode != nil || rt.copyCandidate != nil | |
| 124 | + | rt.copyMu.Unlock() | |
| 125 | + | if !pointer.valid { | |
| 126 | + | if active && ev.Type == mouse.Release { | |
| 127 | + | rt.copyMu.Lock() | |
| 128 | + | if rt.copyPointerEpoch == epoch { | |
| 129 | + | rt.invalidateCopyPointerLocked(false) | |
| 130 | + | } | |
| 131 | + | rt.copyMu.Unlock() | |
| 132 | + | return true | |
| 133 | + | } | |
| 134 | + | return false | |
| 135 | + | } | |
| 136 | + | if active { | |
| 137 | + | if ev.Type == mouse.Release { | |
| 138 | + | rt.copyMu.Lock() | |
| 139 | + | if rt.copyPointerEpoch == epoch { | |
| 140 | + | rt.invalidateCopyPointerLocked(false) | |
| 141 | + | } | |
| 142 | + | rt.copyMu.Unlock() | |
| 143 | + | return true | |
| 144 | + | } | |
| 145 | + | return false | |
| 146 | + | } | |
| 147 | + | pointer.pane.mu.Lock() | |
| 148 | + | mouseMode, _ := pointer.pane.screen.MouseMode() | |
| 149 | + | altScreen := pointer.pane.screen.AltScreenActive() | |
| 150 | + | pointer.pane.mu.Unlock() | |
| 151 | + | if mouseMode != 0 || altScreen { | |
| 152 | + | rt.copyMu.Lock() | |
| 153 | + | if rt.copyPointerEpoch == epoch { | |
| 154 | + | rt.invalidateCopyPointerLocked(true) | |
| 155 | + | } | |
| 156 | + | rt.copyMu.Unlock() | |
| 157 | + | return false // preserve child forwarding when it enabled mouse reporting. | |
| 158 | + | } | |
| 159 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |
| 160 | + | if ev.Type == mouse.Motion { | |
| 161 | + | rt.copyMu.Lock() | |
| 162 | + | if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |
| 163 | + | rt.copyClick.dragged = true | |
| 164 | + | } | |
| 165 | + | rt.copyMu.Unlock() | |
| 166 | + | } | |
| 167 | + | if ev.Type == mouse.Release { | |
| 168 | + | rt.copyMu.Lock() | |
| 169 | + | if rt.copyPointerEpoch == epoch { | |
| 170 | + | rt.invalidateCopyPointerLocked(false) | |
| 171 | + | } | |
| 172 | + | rt.copyMu.Unlock() | |
| 173 | + | return true | |
| 174 | + | } | |
| 175 | + | if !ok { | |
| 176 | + | return true | |
| 177 | + | } | |
| 178 | + | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 179 | + | mode.StartCharacterSelection(pointer.press) | |
| 180 | + | mode.ExtendCharacterSelection(mapped.pos) | |
| 181 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 182 | + | if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | pointer.dragging = true | |
| 186 | + | runtime.copyPointer = pointer | |
| 187 | + | }) { | |
| 188 | + | return true | |
| 189 | + | } | |
| 190 | + | d.invalidateRender(sess, ac, true, "input.go") | |
| 191 | + | return true | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | type copyMouseInputSnapshot struct { | |
| 195 | + | mode *scopy.Mode | |
| 196 | + | pane *pane | |
| 197 | + | document *scopy.Document | |
| 198 | + | viewportTop int | |
| 199 | + | pointer copyPointerState | |
| 200 | + | epoch uint64 | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // snapshotCopyMouseInput captures every mutable copy-mode value needed for a | |
| 204 | + | // mapping while copyMu is held. Mapping must never inspect Mode afterwards. | |
| 205 | + | func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) { | |
| 206 | + | if rt == nil { | |
| 207 | + | return copyMouseInputSnapshot{}, false | |
| 208 | + | } | |
| 209 | + | rt.copyMu.Lock() | |
| 210 | + | defer rt.copyMu.Unlock() | |
| 211 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 212 | + | return copyMouseInputSnapshot{}, false | |
| 213 | + | } | |
| 214 | + | return copyMouseInputSnapshot{ | |
| 215 | + | mode: rt.copyMode, | |
| 216 | + | pane: rt.copyPane, | |
| 217 | + | document: rt.copyDocument, | |
| 218 | + | viewportTop: rt.copyMode.ViewportTop, | |
| 219 | + | pointer: rt.copyPointer, | |
| 220 | + | epoch: rt.copyPointerEpoch, | |
| 221 | + | }, true | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // invalidateSnapshotCopyPointer clears only the pointer captured with the | |
| 225 | + | // input snapshot. Mapping runs outside copyMu, so a newer press can otherwise | |
| 226 | + | // be erased by an older rejected event. | |
| 227 | + | func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) { | |
| 228 | + | rt.copyMu.Lock() | |
| 229 | + | defer rt.copyMu.Unlock() | |
| 230 | + | if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode || | |
| 231 | + | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | |
| 232 | + | return | |
| 233 | + | } | |
| 234 | + | rt.invalidateCopyPointerLocked(true) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |
| 238 | + | // press time. Geometry is resolved after copyMu is released to preserve the | |
| 239 | + | // daemon's copyMu -> tab/pane lock prohibition. | |
| 240 | + | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |
| 241 | + | rt := ac.overlays | |
| 242 | + | snapshot, active := snapshotCopyMouseInput(rt) | |
| 243 | + | if !active { | |
| 244 | + | return | |
| 245 | + | } | |
| 246 | + | if d.beforeCopyMouseMap != nil { | |
| 247 | + | d.beforeCopyMouseMap() | |
| 248 | + | } | |
| 249 | + | cfg := d.currentFloatingConfig() | |
| 250 | + | // Once pressed, use the committed rectangle captured by that press. A | |
| 251 | + | // later floating/layout change must not turn an in-flight drag into a hit | |
| 252 | + | // on a different pane. | |
| 253 | + | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | |
| 254 | + | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | |
| 255 | + | if ev.Type == mouse.Release && !ok { | |
| 256 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | if ok { | |
| 260 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry) | |
| 261 | + | } | |
| 262 | + | return | |
| 263 | + | } | |
| 264 | + | tb.mu.Lock() | |
| 265 | + | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |
| 266 | + | if !ok && ev.Type == mouse.Press { | |
| 267 | + | // Preserve the established press-on-another-pane behavior: copy mode | |
| 268 | + | // exits rather than retargeting its immutable document. | |
| 269 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { | |
| 270 | + | focusPlacementLocked(tb, hit.pane.id) | |
| 271 | + | d.applyLayoutLocked(tb) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | tb.mu.Unlock() | |
| 275 | + | if !ok { | |
| 276 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 277 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 278 | + | } | |
| 279 | + | return | |
| 280 | + | } | |
| 281 | + | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |
| 282 | + | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |
| 283 | + | if !mappedOK { | |
| 284 | + | if ev.Type == mouse.Press { | |
| 285 | + | otherPane := false | |
| 286 | + | tb.mu.Lock() | |
| 287 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 288 | + | otherPane = true | |
| 289 | + | if tb.tree != nil { | |
| 290 | + | focusPlacementLocked(tb, hit.pane.id) | |
| 291 | + | d.applyLayoutLocked(tb) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | tb.mu.Unlock() | |
| 295 | + | if otherPane { | |
| 296 | + | d.exitCopyMode(ac) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 300 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 301 | + | } | |
| 302 | + | return | |
| 303 | + | } | |
| 304 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, geometry) | |
| 305 | + | } | |
| @@ -0,0 +1,305 @@ | |||||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |||
| 2 | + | package daemon | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // handleCopyMouse owns copy-mode mouse routing. A release is lifecycle input | |||
| 10 | + | // even while visual search owns all other copy-mode mouse input. | |||
| 11 | + | func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) bool { | |||
| 12 | + | rt := ac.overlays | |||
| 13 | + | if !rt.copyActive() { | |||
| 14 | + | return false | |||
| 15 | + | } | |||
| 16 | + | if ev.Button == mouse.Left && ev.Type == mouse.Release { | |||
| 17 | + | rt.copyMu.Lock() | |||
| 18 | + | rt.invalidateCopyPointerLocked(false) | |||
| 19 | + | rt.copyMu.Unlock() | |||
| 20 | + | return true | |||
| 21 | + | } | |||
| 22 | + | if rt.copySearchActive() { | |||
| 23 | + | return true | |||
| 24 | + | } | |||
| 25 | + | switch ev.Button { | |||
| 26 | + | case mouse.WheelUp: | |||
| 27 | + | d.copyWheel(sess, ac, -3) | |||
| 28 | + | case mouse.WheelDown: | |||
| 29 | + | d.copyWheel(sess, ac, 3) | |||
| 30 | + | case mouse.Left: | |||
| 31 | + | d.handleActiveCopyMouse(sess, ac, tb, ev) | |||
| 32 | + | } | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // invalidateRejectedLeftPointer clears a stale drag before returning from an | |||
| 37 | + | // event that cannot be routed to terminal content. It never runs while tab or | |||
| 38 | + | // pane locks are held, preserving the copyMu -> tab/pane lock prohibition. | |||
| 39 | + | func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |||
| 40 | + | if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) { | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | rt.copyMu.Lock() | |||
| 44 | + | rt.invalidateCopyPointerLocked(true) | |||
| 45 | + | rt.copyMu.Unlock() | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // handleFreshCopyPress is the only normal-screen press path that creates a | |||
| 49 | + | // selection candidate. Its geometry comes from the same frame-absolute hit | |||
| 50 | + | // test used by active copy mode, never from pane-local synthetic rectangles. | |||
| 51 | + | func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |||
| 52 | + | if routed == nil { | |||
| 53 | + | return false | |||
| 54 | + | } | |||
| 55 | + | tb.mu.Lock() | |||
| 56 | + | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |||
| 57 | + | tb.mu.Unlock() | |||
| 58 | + | if !ok || geometry.pane != routed { | |||
| 59 | + | ac.overlays.copyMu.Lock() | |||
| 60 | + | ac.overlays.invalidateCopyPointerLocked(true) | |||
| 61 | + | ac.overlays.copyMu.Unlock() | |||
| 62 | + | return true | |||
| 63 | + | } | |||
| 64 | + | geometry.pane.mu.Lock() | |||
| 65 | + | mouseMode, _ := geometry.pane.screen.MouseMode() | |||
| 66 | + | altScreen := geometry.pane.screen.AltScreenActive() | |||
| 67 | + | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |||
| 68 | + | geometry.pane.mu.Unlock() | |||
| 69 | + | if mouseMode != 0 || altScreen { | |||
| 70 | + | return false // child forwarding retains its existing raw-byte path. | |||
| 71 | + | } | |||
| 72 | + | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |||
| 73 | + | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |||
| 74 | + | if !ok { | |||
| 75 | + | ac.overlays.copyMu.Lock() | |||
| 76 | + | ac.overlays.invalidateCopyPointerLocked(true) | |||
| 77 | + | ac.overlays.copyMu.Unlock() | |||
| 78 | + | return true | |||
| 79 | + | } | |||
| 80 | + | rt := ac.overlays | |||
| 81 | + | rt.copyMu.Lock() | |||
| 82 | + | if rt.copyMode != nil || rt.copyCandidate != nil { | |||
| 83 | + | rt.copyMu.Unlock() | |||
| 84 | + | return true | |||
| 85 | + | } | |||
| 86 | + | rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |||
| 87 | + | pointer := rt.copyPointer | |||
| 88 | + | now := d.clock.Now() | |||
| 89 | + | doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now) | |||
| 90 | + | if doubleClick { | |||
| 91 | + | rt.copyClick = copyClickCandidate{} | |||
| 92 | + | } else { | |||
| 93 | + | rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now} | |||
| 94 | + | } | |||
| 95 | + | rt.copyMu.Unlock() | |||
| 96 | + | if !doubleClick { | |||
| 97 | + | return true | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | selectedWord := false | |||
| 101 | + | if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) { | |||
| 102 | + | mode.SetPosition(mapped.pos) | |||
| 103 | + | selectedWord = mode.SelectWordAt(mapped.pos) | |||
| 104 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |||
| 105 | + | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | pointer.wordDrag = selectedWord | |||
| 109 | + | runtime.copyPointer = pointer | |||
| 110 | + | }) { | |||
| 111 | + | d.invalidateRender(sess, ac, true, "input.go") | |||
| 112 | + | } | |||
| 113 | + | return true | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // handleFreshCopyPointer maps a pre-copy drag against its press-owned document | |||
| 117 | + | // and geometry. It is intentionally independent of the currently hovered pane. | |||
| 118 | + | func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool { | |||
| 119 | + | rt := ac.overlays | |||
| 120 | + | rt.copyMu.Lock() | |||
| 121 | + | pointer := rt.copyPointer | |||
| 122 | + | epoch := rt.copyPointerEpoch | |||
| 123 | + | active := rt.copyMode != nil || rt.copyCandidate != nil | |||
| 124 | + | rt.copyMu.Unlock() | |||
| 125 | + | if !pointer.valid { | |||
| 126 | + | if active && ev.Type == mouse.Release { | |||
| 127 | + | rt.copyMu.Lock() | |||
| 128 | + | if rt.copyPointerEpoch == epoch { | |||
| 129 | + | rt.invalidateCopyPointerLocked(false) | |||
| 130 | + | } | |||
| 131 | + | rt.copyMu.Unlock() | |||
| 132 | + | return true | |||
| 133 | + | } | |||
| 134 | + | return false | |||
| 135 | + | } | |||
| 136 | + | if active { | |||
| 137 | + | if ev.Type == mouse.Release { | |||
| 138 | + | rt.copyMu.Lock() | |||
| 139 | + | if rt.copyPointerEpoch == epoch { | |||
| 140 | + | rt.invalidateCopyPointerLocked(false) | |||
| 141 | + | } | |||
| 142 | + | rt.copyMu.Unlock() | |||
| 143 | + | return true | |||
| 144 | + | } | |||
| 145 | + | return false | |||
| 146 | + | } | |||
| 147 | + | pointer.pane.mu.Lock() | |||
| 148 | + | mouseMode, _ := pointer.pane.screen.MouseMode() | |||
| 149 | + | altScreen := pointer.pane.screen.AltScreenActive() | |||
| 150 | + | pointer.pane.mu.Unlock() | |||
| 151 | + | if mouseMode != 0 || altScreen { | |||
| 152 | + | rt.copyMu.Lock() | |||
| 153 | + | if rt.copyPointerEpoch == epoch { | |||
| 154 | + | rt.invalidateCopyPointerLocked(true) | |||
| 155 | + | } | |||
| 156 | + | rt.copyMu.Unlock() | |||
| 157 | + | return false // preserve child forwarding when it enabled mouse reporting. | |||
| 158 | + | } | |||
| 159 | + | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |||
| 160 | + | if ev.Type == mouse.Motion { | |||
| 161 | + | rt.copyMu.Lock() | |||
| 162 | + | if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |||
| 163 | + | rt.copyClick.dragged = true | |||
| 164 | + | } | |||
| 165 | + | rt.copyMu.Unlock() | |||
| 166 | + | } | |||
| 167 | + | if ev.Type == mouse.Release { | |||
| 168 | + | rt.copyMu.Lock() | |||
| 169 | + | if rt.copyPointerEpoch == epoch { | |||
| 170 | + | rt.invalidateCopyPointerLocked(false) | |||
| 171 | + | } | |||
| 172 | + | rt.copyMu.Unlock() | |||
| 173 | + | return true | |||
| 174 | + | } | |||
| 175 | + | if !ok { | |||
| 176 | + | return true | |||
| 177 | + | } | |||
| 178 | + | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |||
| 179 | + | mode.StartCharacterSelection(pointer.press) | |||
| 180 | + | mode.ExtendCharacterSelection(mapped.pos) | |||
| 181 | + | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |||
| 182 | + | if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |||
| 183 | + | return | |||
| 184 | + | } | |||
| 185 | + | pointer.dragging = true | |||
| 186 | + | runtime.copyPointer = pointer | |||
| 187 | + | }) { | |||
| 188 | + | return true | |||
| 189 | + | } | |||
| 190 | + | d.invalidateRender(sess, ac, true, "input.go") | |||
| 191 | + | return true | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | type copyMouseInputSnapshot struct { | |||
| 195 | + | mode *scopy.Mode | |||
| 196 | + | pane *pane | |||
| 197 | + | document *scopy.Document | |||
| 198 | + | viewportTop int | |||
| 199 | + | pointer copyPointerState | |||
| 200 | + | epoch uint64 | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // snapshotCopyMouseInput captures every mutable copy-mode value needed for a | |||
| 204 | + | // mapping while copyMu is held. Mapping must never inspect Mode afterwards. | |||
| 205 | + | func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) { | |||
| 206 | + | if rt == nil { | |||
| 207 | + | return copyMouseInputSnapshot{}, false | |||
| 208 | + | } | |||
| 209 | + | rt.copyMu.Lock() | |||
| 210 | + | defer rt.copyMu.Unlock() | |||
| 211 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |||
| 212 | + | return copyMouseInputSnapshot{}, false | |||
| 213 | + | } | |||
| 214 | + | return copyMouseInputSnapshot{ | |||
| 215 | + | mode: rt.copyMode, | |||
| 216 | + | pane: rt.copyPane, | |||
| 217 | + | document: rt.copyDocument, | |||
| 218 | + | viewportTop: rt.copyMode.ViewportTop, | |||
| 219 | + | pointer: rt.copyPointer, | |||
| 220 | + | epoch: rt.copyPointerEpoch, | |||
| 221 | + | }, true | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // invalidateSnapshotCopyPointer clears only the pointer captured with the | |||
| 225 | + | // input snapshot. Mapping runs outside copyMu, so a newer press can otherwise | |||
| 226 | + | // be erased by an older rejected event. | |||
| 227 | + | func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) { | |||
| 228 | + | rt.copyMu.Lock() | |||
| 229 | + | defer rt.copyMu.Unlock() | |||
| 230 | + | if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode || | |||
| 231 | + | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | |||
| 232 | + | return | |||
| 233 | + | } | |||
| 234 | + | rt.invalidateCopyPointerLocked(true) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |||
| 238 | + | // press time. Geometry is resolved after copyMu is released to preserve the | |||
| 239 | + | // daemon's copyMu -> tab/pane lock prohibition. | |||
| 240 | + | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |||
| 241 | + | rt := ac.overlays | |||
| 242 | + | snapshot, active := snapshotCopyMouseInput(rt) | |||
| 243 | + | if !active { | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | if d.beforeCopyMouseMap != nil { | |||
| 247 | + | d.beforeCopyMouseMap() | |||
| 248 | + | } | |||
| 249 | + | cfg := d.currentFloatingConfig() | |||
| 250 | + | // Once pressed, use the committed rectangle captured by that press. A | |||
| 251 | + | // later floating/layout change must not turn an in-flight drag into a hit | |||
| 252 | + | // on a different pane. | |||
| 253 | + | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | |||
| 254 | + | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | |||
| 255 | + | if ev.Type == mouse.Release && !ok { | |||
| 256 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | if ok { | |||
| 260 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry) | |||
| 261 | + | } | |||
| 262 | + | return | |||
| 263 | + | } | |||
| 264 | + | tb.mu.Lock() | |||
| 265 | + | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |||
| 266 | + | if !ok && ev.Type == mouse.Press { | |||
| 267 | + | // Preserve the established press-on-another-pane behavior: copy mode | |||
| 268 | + | // exits rather than retargeting its immutable document. | |||
| 269 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { | |||
| 270 | + | focusPlacementLocked(tb, hit.pane.id) | |||
| 271 | + | d.applyLayoutLocked(tb) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | tb.mu.Unlock() | |||
| 275 | + | if !ok { | |||
| 276 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |||
| 277 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |||
| 278 | + | } | |||
| 279 | + | return | |||
| 280 | + | } | |||
| 281 | + | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |||
| 282 | + | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |||
| 283 | + | if !mappedOK { | |||
| 284 | + | if ev.Type == mouse.Press { | |||
| 285 | + | otherPane := false | |||
| 286 | + | tb.mu.Lock() | |||
| 287 | + | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |||
| 288 | + | otherPane = true | |||
| 289 | + | if tb.tree != nil { | |||
| 290 | + | focusPlacementLocked(tb, hit.pane.id) | |||
| 291 | + | d.applyLayoutLocked(tb) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | tb.mu.Unlock() | |||
| 295 | + | if otherPane { | |||
| 296 | + | d.exitCopyMode(ac) | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |||
| 300 | + | invalidateSnapshotCopyPointer(rt, snapshot) | |||
| 301 | + | } | |||
| 302 | + | return | |||
| 303 | + | } | |||
| 304 | + | d.copyMouse(sess, ac, ev, mapped, snapshot, geometry) | |||
| 305 | + | } | |||
M internal/usecase/daemon/copymode_mouse_test.go +65 -0
| @@ -140,6 +140,71 @@ func TestCopyDoubleClick(t *testing.T) { | |||
| 140 | 140 | }) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) { | |
| 144 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |
| 145 | + | ||
| 146 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |
| 147 | + | ac.overlays.copyMu.Lock() | |
| 148 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 149 | + | epoch := ac.overlays.copyPointerEpoch | |
| 150 | + | ac.overlays.copyMu.Unlock() | |
| 151 | + | ||
| 152 | + | copyMouseInput(d, sess, ac, "/") | |
| 153 | + | require.True(t, ac.overlays.copySearchActive()) | |
| 154 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2m") | |
| 155 | + | ||
| 156 | + | ac.overlays.copyMu.Lock() | |
| 157 | + | require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input") | |
| 158 | + | require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch") | |
| 159 | + | ac.overlays.copyMu.Unlock() | |
| 160 | + | ||
| 161 | + | copyMouseInput(d, sess, ac, "\x03") | |
| 162 | + | require.False(t, ac.overlays.copySearchActive()) | |
| 163 | + | copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M") | |
| 164 | + | ||
| 165 | + | ac.overlays.copyMu.Lock() | |
| 166 | + | selection := ac.overlays.copyMode.Selection() | |
| 167 | + | ac.overlays.copyMu.Unlock() | |
| 168 | + | require.True(t, selection.Enabled) | |
| 169 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor) | |
| 170 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) { | |
| 174 | + | for _, tc := range []struct { | |
| 175 | + | name string | |
| 176 | + | text string | |
| 177 | + | setup func(*Daemon) | |
| 178 | + | }{ | |
| 179 | + | {name: "whitespace", text: "alpha beta"}, | |
| 180 | + | {name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) { | |
| 181 | + | cfg := domain.Defaults() | |
| 182 | + | cfg.Copy.WordSeparators = "/" | |
| 183 | + | d.ApplyConfig(cfg) | |
| 184 | + | }}, | |
| 185 | + | } { | |
| 186 | + | t.Run(tc.name, func(t *testing.T) { | |
| 187 | + | d, sess, ac, clock := mouseCopyHarness(t, tc.text) | |
| 188 | + | d.exitCopyMode(ac) | |
| 189 | + | if tc.setup != nil { | |
| 190 | + | tc.setup(d) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m") | |
| 194 | + | clock.Advance(time.Millisecond) | |
| 195 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M") | |
| 196 | + | ||
| 197 | + | ac.overlays.copyMu.Lock() | |
| 198 | + | mode := ac.overlays.copyMode | |
| 199 | + | selection := mode.Selection() | |
| 200 | + | cursor := mode.Cursor() | |
| 201 | + | ac.overlays.copyMu.Unlock() | |
| 202 | + | require.False(t, selection.Enabled) | |
| 203 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor) | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 143 | 208 | func TestCopyWordDragAndOSC52(t *testing.T) { | |
| 144 | 209 | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta") | |
| 145 | 210 | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | |
| @@ -140,6 +140,71 @@ func TestCopyDoubleClick(t *testing.T) { | |||||
| 140 | }) | 140 | }) | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | + | func TestCopySearchReleaseInvalidatesPointerBeforeNextDrag(t *testing.T) { | |||
| 144 | + | d, sess, ac, _ := mouseCopyHarness(t, "alpha bravo") | |||
| 145 | + | ||||
| 146 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M") | |||
| 147 | + | ac.overlays.copyMu.Lock() | |||
| 148 | + | require.True(t, ac.overlays.copyPointer.valid) | |||
| 149 | + | epoch := ac.overlays.copyPointerEpoch | |||
| 150 | + | ac.overlays.copyMu.Unlock() | |||
| 151 | + | ||||
| 152 | + | copyMouseInput(d, sess, ac, "/") | |||
| 153 | + | require.True(t, ac.overlays.copySearchActive()) | |||
| 154 | + | copyMouseInput(d, sess, ac, "\x1b[<0;1;2m") | |||
| 155 | + | ||||
| 156 | + | ac.overlays.copyMu.Lock() | |||
| 157 | + | require.False(t, ac.overlays.copyPointer.valid, "release must clear the pointer while search owns mouse input") | |||
| 158 | + | require.Greater(t, ac.overlays.copyPointerEpoch, epoch, "release must advance the pointer epoch") | |||
| 159 | + | ac.overlays.copyMu.Unlock() | |||
| 160 | + | ||||
| 161 | + | copyMouseInput(d, sess, ac, "\x03") | |||
| 162 | + | require.False(t, ac.overlays.copySearchActive()) | |||
| 163 | + | copyMouseInput(d, sess, ac, "\x1b[<0;7;2M\x1b[<32;9;2M") | |||
| 164 | + | ||||
| 165 | + | ac.overlays.copyMu.Lock() | |||
| 166 | + | selection := ac.overlays.copyMode.Selection() | |||
| 167 | + | ac.overlays.copyMu.Unlock() | |||
| 168 | + | require.True(t, selection.Enabled) | |||
| 169 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 6}, selection.Anchor) | |||
| 170 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 8}, selection.Active) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestNormalScreenPassiveDoubleClickSetsMappedCursor(t *testing.T) { | |||
| 174 | + | for _, tc := range []struct { | |||
| 175 | + | name string | |||
| 176 | + | text string | |||
| 177 | + | setup func(*Daemon) | |||
| 178 | + | }{ | |||
| 179 | + | {name: "whitespace", text: "alpha beta"}, | |||
| 180 | + | {name: "configured separator", text: "alpha/beta", setup: func(d *Daemon) { | |||
| 181 | + | cfg := domain.Defaults() | |||
| 182 | + | cfg.Copy.WordSeparators = "/" | |||
| 183 | + | d.ApplyConfig(cfg) | |||
| 184 | + | }}, | |||
| 185 | + | } { | |||
| 186 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 187 | + | d, sess, ac, clock := mouseCopyHarness(t, tc.text) | |||
| 188 | + | d.exitCopyMode(ac) | |||
| 189 | + | if tc.setup != nil { | |||
| 190 | + | tc.setup(d) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M\x1b[<0;6;2m") | |||
| 194 | + | clock.Advance(time.Millisecond) | |||
| 195 | + | copyMouseInput(d, sess, ac, "\x1b[<0;6;2M") | |||
| 196 | + | ||||
| 197 | + | ac.overlays.copyMu.Lock() | |||
| 198 | + | mode := ac.overlays.copyMode | |||
| 199 | + | selection := mode.Selection() | |||
| 200 | + | cursor := mode.Cursor() | |||
| 201 | + | ac.overlays.copyMu.Unlock() | |||
| 202 | + | require.False(t, selection.Enabled) | |||
| 203 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 5}, cursor) | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 143 | func TestCopyWordDragAndOSC52(t *testing.T) { | 208 | func TestCopyWordDragAndOSC52(t *testing.T) { | ||
| 144 | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta") | 209 | d, sess, ac, clock := mouseCopyHarness(t, "alpha beta", "gamma delta") | ||
| 145 | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | 210 | copyMouseInput(d, sess, ac, "\x1b[<0;1;2M\x1b[<0;1;2m") | ||
M internal/usecase/daemon/input.go +1 -283
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | ||
| 8 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 8 | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 10 | "github.com/bnema/vev/internal/usecase/mouse" | |
| @@ -50,18 +49,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 50 | 49 | return | |
| 51 | 50 | } | |
| 52 | 51 | ||
| 53 | - | if rt.copyActive() { | |
| 54 | - | if rt.copySearchActive() { | |
| 55 | - | return | |
| 56 | - | } | |
| 57 | - | switch ev.Button { | |
| 58 | - | case mouse.WheelUp: | |
| 59 | - | d.copyWheel(sess, ac, -3) | |
| 60 | - | case mouse.WheelDown: | |
| 61 | - | d.copyWheel(sess, ac, 3) | |
| 62 | - | case mouse.Left: | |
| 63 | - | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 64 | - | } | |
| 52 | + | if d.handleCopyMouse(sess, ac, tb, ev) { | |
| 65 | 53 | return | |
| 66 | 54 | } | |
| 67 | 55 | // A fresh drag is pinned to its press geometry. Handle later events before | |
| @@ -158,276 +146,6 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 158 | 146 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |
| 159 | 147 | } | |
| 160 | 148 | ||
| 161 | - | // invalidateRejectedLeftPointer clears a stale drag before returning from an | |
| 162 | - | // event that cannot be routed to terminal content. It never runs while tab or | |
| 163 | - | // pane locks are held, preserving the copyMu -> tab/pane lock prohibition. | |
| 164 | - | func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |
| 165 | - | if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) { | |
| 166 | - | return | |
| 167 | - | } | |
| 168 | - | rt.copyMu.Lock() | |
| 169 | - | rt.invalidateCopyPointerLocked(true) | |
| 170 | - | rt.copyMu.Unlock() | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | // handleFreshCopyPress is the only normal-screen press path that creates a | |
| 174 | - | // selection candidate. Its geometry comes from the same frame-absolute hit | |
| 175 | - | // test used by active copy mode, never from pane-local synthetic rectangles. | |
| 176 | - | func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |
| 177 | - | if routed == nil { | |
| 178 | - | return false | |
| 179 | - | } | |
| 180 | - | tb.mu.Lock() | |
| 181 | - | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |
| 182 | - | tb.mu.Unlock() | |
| 183 | - | if !ok || geometry.pane != routed { | |
| 184 | - | ac.overlays.copyMu.Lock() | |
| 185 | - | ac.overlays.invalidateCopyPointerLocked(true) | |
| 186 | - | ac.overlays.copyMu.Unlock() | |
| 187 | - | return true | |
| 188 | - | } | |
| 189 | - | geometry.pane.mu.Lock() | |
| 190 | - | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 191 | - | altScreen := geometry.pane.screen.AltScreenActive() | |
| 192 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |
| 193 | - | geometry.pane.mu.Unlock() | |
| 194 | - | if mouseMode != 0 || altScreen { | |
| 195 | - | return false // child forwarding retains its existing raw-byte path. | |
| 196 | - | } | |
| 197 | - | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |
| 198 | - | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |
| 199 | - | if !ok { | |
| 200 | - | ac.overlays.copyMu.Lock() | |
| 201 | - | ac.overlays.invalidateCopyPointerLocked(true) | |
| 202 | - | ac.overlays.copyMu.Unlock() | |
| 203 | - | return true | |
| 204 | - | } | |
| 205 | - | rt := ac.overlays | |
| 206 | - | rt.copyMu.Lock() | |
| 207 | - | if rt.copyMode != nil || rt.copyCandidate != nil { | |
| 208 | - | rt.copyMu.Unlock() | |
| 209 | - | return true | |
| 210 | - | } | |
| 211 | - | rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |
| 212 | - | pointer := rt.copyPointer | |
| 213 | - | now := d.clock.Now() | |
| 214 | - | doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now) | |
| 215 | - | if doubleClick { | |
| 216 | - | rt.copyClick = copyClickCandidate{} | |
| 217 | - | } else { | |
| 218 | - | rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now} | |
| 219 | - | } | |
| 220 | - | rt.copyMu.Unlock() | |
| 221 | - | if !doubleClick { | |
| 222 | - | return true | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | selectedWord := false | |
| 226 | - | if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) { | |
| 227 | - | selectedWord = mode.SelectWordAt(mapped.pos) | |
| 228 | - | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 229 | - | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 230 | - | return | |
| 231 | - | } | |
| 232 | - | pointer.wordDrag = selectedWord | |
| 233 | - | runtime.copyPointer = pointer | |
| 234 | - | }) { | |
| 235 | - | d.invalidateRender(sess, ac, true, "input.go") | |
| 236 | - | } | |
| 237 | - | return true | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // handleFreshCopyPointer maps a pre-copy drag against its press-owned document | |
| 241 | - | // and geometry. It is intentionally independent of the currently hovered pane. | |
| 242 | - | func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool { | |
| 243 | - | rt := ac.overlays | |
| 244 | - | rt.copyMu.Lock() | |
| 245 | - | pointer := rt.copyPointer | |
| 246 | - | epoch := rt.copyPointerEpoch | |
| 247 | - | active := rt.copyMode != nil || rt.copyCandidate != nil | |
| 248 | - | rt.copyMu.Unlock() | |
| 249 | - | if !pointer.valid { | |
| 250 | - | if active && ev.Type == mouse.Release { | |
| 251 | - | rt.copyMu.Lock() | |
| 252 | - | if rt.copyPointerEpoch == epoch { | |
| 253 | - | rt.invalidateCopyPointerLocked(false) | |
| 254 | - | } | |
| 255 | - | rt.copyMu.Unlock() | |
| 256 | - | return true | |
| 257 | - | } | |
| 258 | - | return false | |
| 259 | - | } | |
| 260 | - | if active { | |
| 261 | - | if ev.Type == mouse.Release { | |
| 262 | - | rt.copyMu.Lock() | |
| 263 | - | if rt.copyPointerEpoch == epoch { | |
| 264 | - | rt.invalidateCopyPointerLocked(false) | |
| 265 | - | } | |
| 266 | - | rt.copyMu.Unlock() | |
| 267 | - | return true | |
| 268 | - | } | |
| 269 | - | return false | |
| 270 | - | } | |
| 271 | - | pointer.pane.mu.Lock() | |
| 272 | - | mouseMode, _ := pointer.pane.screen.MouseMode() | |
| 273 | - | altScreen := pointer.pane.screen.AltScreenActive() | |
| 274 | - | pointer.pane.mu.Unlock() | |
| 275 | - | if mouseMode != 0 || altScreen { | |
| 276 | - | rt.copyMu.Lock() | |
| 277 | - | if rt.copyPointerEpoch == epoch { | |
| 278 | - | rt.invalidateCopyPointerLocked(true) | |
| 279 | - | } | |
| 280 | - | rt.copyMu.Unlock() | |
| 281 | - | return false // preserve child forwarding when it enabled mouse reporting. | |
| 282 | - | } | |
| 283 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |
| 284 | - | if ev.Type == mouse.Motion { | |
| 285 | - | rt.copyMu.Lock() | |
| 286 | - | if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |
| 287 | - | rt.copyClick.dragged = true | |
| 288 | - | } | |
| 289 | - | rt.copyMu.Unlock() | |
| 290 | - | } | |
| 291 | - | if ev.Type == mouse.Release { | |
| 292 | - | rt.copyMu.Lock() | |
| 293 | - | if rt.copyPointerEpoch == epoch { | |
| 294 | - | rt.invalidateCopyPointerLocked(false) | |
| 295 | - | } | |
| 296 | - | rt.copyMu.Unlock() | |
| 297 | - | return true | |
| 298 | - | } | |
| 299 | - | if !ok { | |
| 300 | - | return true | |
| 301 | - | } | |
| 302 | - | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 303 | - | mode.StartCharacterSelection(pointer.press) | |
| 304 | - | mode.ExtendCharacterSelection(mapped.pos) | |
| 305 | - | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| 306 | - | if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |
| 307 | - | return | |
| 308 | - | } | |
| 309 | - | pointer.dragging = true | |
| 310 | - | runtime.copyPointer = pointer | |
| 311 | - | }) { | |
| 312 | - | return true | |
| 313 | - | } | |
| 314 | - | d.invalidateRender(sess, ac, true, "input.go") | |
| 315 | - | return true | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | type copyMouseInputSnapshot struct { | |
| 319 | - | mode *scopy.Mode | |
| 320 | - | pane *pane | |
| 321 | - | document *scopy.Document | |
| 322 | - | viewportTop int | |
| 323 | - | pointer copyPointerState | |
| 324 | - | epoch uint64 | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | // snapshotCopyMouseInput captures every mutable copy-mode value needed for a | |
| 328 | - | // mapping while copyMu is held. Mapping must never inspect Mode afterwards. | |
| 329 | - | func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) { | |
| 330 | - | if rt == nil { | |
| 331 | - | return copyMouseInputSnapshot{}, false | |
| 332 | - | } | |
| 333 | - | rt.copyMu.Lock() | |
| 334 | - | defer rt.copyMu.Unlock() | |
| 335 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 336 | - | return copyMouseInputSnapshot{}, false | |
| 337 | - | } | |
| 338 | - | return copyMouseInputSnapshot{ | |
| 339 | - | mode: rt.copyMode, | |
| 340 | - | pane: rt.copyPane, | |
| 341 | - | document: rt.copyDocument, | |
| 342 | - | viewportTop: rt.copyMode.ViewportTop, | |
| 343 | - | pointer: rt.copyPointer, | |
| 344 | - | epoch: rt.copyPointerEpoch, | |
| 345 | - | }, true | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | // invalidateSnapshotCopyPointer clears only the pointer captured with the | |
| 349 | - | // input snapshot. Mapping runs outside copyMu, so a newer press can otherwise | |
| 350 | - | // be erased by an older rejected event. | |
| 351 | - | func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) { | |
| 352 | - | rt.copyMu.Lock() | |
| 353 | - | defer rt.copyMu.Unlock() | |
| 354 | - | if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode || | |
| 355 | - | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | |
| 356 | - | return | |
| 357 | - | } | |
| 358 | - | rt.invalidateCopyPointerLocked(true) | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |
| 362 | - | // press time. Geometry is resolved after copyMu is released to preserve the | |
| 363 | - | // daemon's copyMu -> tab/pane lock prohibition. | |
| 364 | - | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |
| 365 | - | rt := ac.overlays | |
| 366 | - | snapshot, active := snapshotCopyMouseInput(rt) | |
| 367 | - | if !active { | |
| 368 | - | return | |
| 369 | - | } | |
| 370 | - | if d.beforeCopyMouseMap != nil { | |
| 371 | - | d.beforeCopyMouseMap() | |
| 372 | - | } | |
| 373 | - | cfg := d.currentFloatingConfig() | |
| 374 | - | // Once pressed, use the committed rectangle captured by that press. A | |
| 375 | - | // later floating/layout change must not turn an in-flight drag into a hit | |
| 376 | - | // on a different pane. | |
| 377 | - | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | |
| 378 | - | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | |
| 379 | - | if ev.Type == mouse.Release && !ok { | |
| 380 | - | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 381 | - | return | |
| 382 | - | } | |
| 383 | - | if ok { | |
| 384 | - | d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry) | |
| 385 | - | } | |
| 386 | - | return | |
| 387 | - | } | |
| 388 | - | tb.mu.Lock() | |
| 389 | - | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |
| 390 | - | if !ok && ev.Type == mouse.Press { | |
| 391 | - | // Preserve the established press-on-another-pane behavior: copy mode | |
| 392 | - | // exits rather than retargeting its immutable document. | |
| 393 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { | |
| 394 | - | focusPlacementLocked(tb, hit.pane.id) | |
| 395 | - | d.applyLayoutLocked(tb) | |
| 396 | - | } | |
| 397 | - | } | |
| 398 | - | tb.mu.Unlock() | |
| 399 | - | if !ok { | |
| 400 | - | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 401 | - | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 402 | - | } | |
| 403 | - | return | |
| 404 | - | } | |
| 405 | - | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |
| 406 | - | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |
| 407 | - | if !mappedOK { | |
| 408 | - | if ev.Type == mouse.Press { | |
| 409 | - | otherPane := false | |
| 410 | - | tb.mu.Lock() | |
| 411 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |
| 412 | - | otherPane = true | |
| 413 | - | if tb.tree != nil { | |
| 414 | - | focusPlacementLocked(tb, hit.pane.id) | |
| 415 | - | d.applyLayoutLocked(tb) | |
| 416 | - | } | |
| 417 | - | } | |
| 418 | - | tb.mu.Unlock() | |
| 419 | - | if otherPane { | |
| 420 | - | d.exitCopyMode(ac) | |
| 421 | - | } | |
| 422 | - | } | |
| 423 | - | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |
| 424 | - | invalidateSnapshotCopyPointer(rt, snapshot) | |
| 425 | - | } | |
| 426 | - | return | |
| 427 | - | } | |
| 428 | - | d.copyMouse(sess, ac, ev, mapped, snapshot, geometry) | |
| 429 | - | } | |
| 430 | - | ||
| 431 | 149 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | |
| 432 | 150 | if p == nil { | |
| 433 | 151 | return | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | 7 | ||||
| 8 | - | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 9 | "github.com/bnema/vev/internal/usecase/keys" | 8 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 10 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 11 | "github.com/bnema/vev/internal/usecase/mouse" | 10 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| @@ -50,18 +49,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 50 | return | 49 | return | ||
| 51 | } | 50 | } | ||
| 52 | 51 | ||||
| 53 | - | if rt.copyActive() { | 52 | + | if d.handleCopyMouse(sess, ac, tb, ev) { |
| 54 | - | if rt.copySearchActive() { | |||
| 55 | - | return | |||
| 56 | - | } | |||
| 57 | - | switch ev.Button { | |||
| 58 | - | case mouse.WheelUp: | |||
| 59 | - | d.copyWheel(sess, ac, -3) | |||
| 60 | - | case mouse.WheelDown: | |||
| 61 | - | d.copyWheel(sess, ac, 3) | |||
| 62 | - | case mouse.Left: | |||
| 63 | - | d.handleActiveCopyMouse(sess, ac, tb, ev) | |||
| 64 | - | } | |||
| 65 | return | 53 | return | ||
| 66 | } | 54 | } | ||
| 67 | // A fresh drag is pinned to its press geometry. Handle later events before | 55 | // A fresh drag is pinned to its press geometry. Handle later events before | ||
| @@ -158,276 +146,6 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 158 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | 146 | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | ||
| 159 | } | 147 | } | ||
| 160 | 148 | ||||
| 161 | - | // invalidateRejectedLeftPointer clears a stale drag before returning from an | |||
| 162 | - | // event that cannot be routed to terminal content. It never runs while tab or | |||
| 163 | - | // pane locks are held, preserving the copyMu -> tab/pane lock prohibition. | |||
| 164 | - | func invalidateRejectedLeftPointer(rt *overlayRuntime, ev mouse.Event) { | |||
| 165 | - | if rt == nil || ev.Button != mouse.Left || (ev.Type != mouse.Press && ev.Type != mouse.Release) { | |||
| 166 | - | return | |||
| 167 | - | } | |||
| 168 | - | rt.copyMu.Lock() | |||
| 169 | - | rt.invalidateCopyPointerLocked(true) | |||
| 170 | - | rt.copyMu.Unlock() | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | - | // handleFreshCopyPress is the only normal-screen press path that creates a | |||
| 174 | - | // selection candidate. Its geometry comes from the same frame-absolute hit | |||
| 175 | - | // test used by active copy mode, never from pane-local synthetic rectangles. | |||
| 176 | - | func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab, routed *pane, ev mouse.Event) bool { | |||
| 177 | - | if routed == nil { | |||
| 178 | - | return false | |||
| 179 | - | } | |||
| 180 | - | tb.mu.Lock() | |||
| 181 | - | geometry, ok := hitTestCopyMouseGeometryLocked(tb, d.currentFloatingConfig(), ev.Col, ev.Row) | |||
| 182 | - | tb.mu.Unlock() | |||
| 183 | - | if !ok || geometry.pane != routed { | |||
| 184 | - | ac.overlays.copyMu.Lock() | |||
| 185 | - | ac.overlays.invalidateCopyPointerLocked(true) | |||
| 186 | - | ac.overlays.copyMu.Unlock() | |||
| 187 | - | return true | |||
| 188 | - | } | |||
| 189 | - | geometry.pane.mu.Lock() | |||
| 190 | - | mouseMode, _ := geometry.pane.screen.MouseMode() | |||
| 191 | - | altScreen := geometry.pane.screen.AltScreenActive() | |||
| 192 | - | snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame) | |||
| 193 | - | geometry.pane.mu.Unlock() | |||
| 194 | - | if mouseMode != 0 || altScreen { | |||
| 195 | - | return false // child forwarding retains its existing raw-byte path. | |||
| 196 | - | } | |||
| 197 | - | document := scopy.NewDocument(snapshot, d.currentCopyConfig().WordSeparators) | |||
| 198 | - | mapped, ok := mapCopyMouse(ev, geometry, max(document.Len()-document.Height(), 0), document, false) | |||
| 199 | - | if !ok { | |||
| 200 | - | ac.overlays.copyMu.Lock() | |||
| 201 | - | ac.overlays.invalidateCopyPointerLocked(true) | |||
| 202 | - | ac.overlays.copyMu.Unlock() | |||
| 203 | - | return true | |||
| 204 | - | } | |||
| 205 | - | rt := ac.overlays | |||
| 206 | - | rt.copyMu.Lock() | |||
| 207 | - | if rt.copyMode != nil || rt.copyCandidate != nil { | |||
| 208 | - | rt.copyMu.Unlock() | |||
| 209 | - | return true | |||
| 210 | - | } | |||
| 211 | - | rt.beginCopyPointerLocked(copyPointerState{pane: geometry.pane, document: document, geometry: geometry, press: mapped.pos}) | |||
| 212 | - | pointer := rt.copyPointer | |||
| 213 | - | now := d.clock.Now() | |||
| 214 | - | doubleClick := d.isCopyDoubleClickLocked(rt, geometry.pane, mapped.pos, now) | |||
| 215 | - | if doubleClick { | |||
| 216 | - | rt.copyClick = copyClickCandidate{} | |||
| 217 | - | } else { | |||
| 218 | - | rt.copyClick = copyClickCandidate{valid: true, pane: geometry.pane, pos: mapped.pos, at: now} | |||
| 219 | - | } | |||
| 220 | - | rt.copyMu.Unlock() | |||
| 221 | - | if !doubleClick { | |||
| 222 | - | return true | |||
| 223 | - | } | |||
| 224 | - | ||||
| 225 | - | selectedWord := false | |||
| 226 | - | if d.publishCopyMode(sess, ac, tb, geometry.pane, document, func(mode *scopy.Mode) { | |||
| 227 | - | selectedWord = mode.SelectWordAt(mapped.pos) | |||
| 228 | - | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |||
| 229 | - | if runtime.copyPointerEpoch != pointer.epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |||
| 230 | - | return | |||
| 231 | - | } | |||
| 232 | - | pointer.wordDrag = selectedWord | |||
| 233 | - | runtime.copyPointer = pointer | |||
| 234 | - | }) { | |||
| 235 | - | d.invalidateRender(sess, ac, true, "input.go") | |||
| 236 | - | } | |||
| 237 | - | return true | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | // handleFreshCopyPointer maps a pre-copy drag against its press-owned document | |||
| 241 | - | // and geometry. It is intentionally independent of the currently hovered pane. | |||
| 242 | - | func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mouse.Event) bool { | |||
| 243 | - | rt := ac.overlays | |||
| 244 | - | rt.copyMu.Lock() | |||
| 245 | - | pointer := rt.copyPointer | |||
| 246 | - | epoch := rt.copyPointerEpoch | |||
| 247 | - | active := rt.copyMode != nil || rt.copyCandidate != nil | |||
| 248 | - | rt.copyMu.Unlock() | |||
| 249 | - | if !pointer.valid { | |||
| 250 | - | if active && ev.Type == mouse.Release { | |||
| 251 | - | rt.copyMu.Lock() | |||
| 252 | - | if rt.copyPointerEpoch == epoch { | |||
| 253 | - | rt.invalidateCopyPointerLocked(false) | |||
| 254 | - | } | |||
| 255 | - | rt.copyMu.Unlock() | |||
| 256 | - | return true | |||
| 257 | - | } | |||
| 258 | - | return false | |||
| 259 | - | } | |||
| 260 | - | if active { | |||
| 261 | - | if ev.Type == mouse.Release { | |||
| 262 | - | rt.copyMu.Lock() | |||
| 263 | - | if rt.copyPointerEpoch == epoch { | |||
| 264 | - | rt.invalidateCopyPointerLocked(false) | |||
| 265 | - | } | |||
| 266 | - | rt.copyMu.Unlock() | |||
| 267 | - | return true | |||
| 268 | - | } | |||
| 269 | - | return false | |||
| 270 | - | } | |||
| 271 | - | pointer.pane.mu.Lock() | |||
| 272 | - | mouseMode, _ := pointer.pane.screen.MouseMode() | |||
| 273 | - | altScreen := pointer.pane.screen.AltScreenActive() | |||
| 274 | - | pointer.pane.mu.Unlock() | |||
| 275 | - | if mouseMode != 0 || altScreen { | |||
| 276 | - | rt.copyMu.Lock() | |||
| 277 | - | if rt.copyPointerEpoch == epoch { | |||
| 278 | - | rt.invalidateCopyPointerLocked(true) | |||
| 279 | - | } | |||
| 280 | - | rt.copyMu.Unlock() | |||
| 281 | - | return false // preserve child forwarding when it enabled mouse reporting. | |||
| 282 | - | } | |||
| 283 | - | mapped, ok := mapCopyMouse(ev, pointer.geometry, max(pointer.document.Len()-pointer.document.Height(), 0), pointer.document, ev.Type == mouse.Motion) | |||
| 284 | - | if ev.Type == mouse.Motion { | |||
| 285 | - | rt.copyMu.Lock() | |||
| 286 | - | if rt.copyPointerEpoch == epoch && rt.copyClick.valid && rt.copyClick.pane == pointer.pane { | |||
| 287 | - | rt.copyClick.dragged = true | |||
| 288 | - | } | |||
| 289 | - | rt.copyMu.Unlock() | |||
| 290 | - | } | |||
| 291 | - | if ev.Type == mouse.Release { | |||
| 292 | - | rt.copyMu.Lock() | |||
| 293 | - | if rt.copyPointerEpoch == epoch { | |||
| 294 | - | rt.invalidateCopyPointerLocked(false) | |||
| 295 | - | } | |||
| 296 | - | rt.copyMu.Unlock() | |||
| 297 | - | return true | |||
| 298 | - | } | |||
| 299 | - | if !ok { | |||
| 300 | - | return true | |||
| 301 | - | } | |||
| 302 | - | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |||
| 303 | - | mode.StartCharacterSelection(pointer.press) | |||
| 304 | - | mode.ExtendCharacterSelection(mapped.pos) | |||
| 305 | - | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |||
| 306 | - | if runtime.copyPointerEpoch != epoch || runtime.copyPane != pointer.pane || mode.Document() != pointer.document { | |||
| 307 | - | return | |||
| 308 | - | } | |||
| 309 | - | pointer.dragging = true | |||
| 310 | - | runtime.copyPointer = pointer | |||
| 311 | - | }) { | |||
| 312 | - | return true | |||
| 313 | - | } | |||
| 314 | - | d.invalidateRender(sess, ac, true, "input.go") | |||
| 315 | - | return true | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | type copyMouseInputSnapshot struct { | |||
| 319 | - | mode *scopy.Mode | |||
| 320 | - | pane *pane | |||
| 321 | - | document *scopy.Document | |||
| 322 | - | viewportTop int | |||
| 323 | - | pointer copyPointerState | |||
| 324 | - | epoch uint64 | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | // snapshotCopyMouseInput captures every mutable copy-mode value needed for a | |||
| 328 | - | // mapping while copyMu is held. Mapping must never inspect Mode afterwards. | |||
| 329 | - | func snapshotCopyMouseInput(rt *overlayRuntime) (copyMouseInputSnapshot, bool) { | |||
| 330 | - | if rt == nil { | |||
| 331 | - | return copyMouseInputSnapshot{}, false | |||
| 332 | - | } | |||
| 333 | - | rt.copyMu.Lock() | |||
| 334 | - | defer rt.copyMu.Unlock() | |||
| 335 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |||
| 336 | - | return copyMouseInputSnapshot{}, false | |||
| 337 | - | } | |||
| 338 | - | return copyMouseInputSnapshot{ | |||
| 339 | - | mode: rt.copyMode, | |||
| 340 | - | pane: rt.copyPane, | |||
| 341 | - | document: rt.copyDocument, | |||
| 342 | - | viewportTop: rt.copyMode.ViewportTop, | |||
| 343 | - | pointer: rt.copyPointer, | |||
| 344 | - | epoch: rt.copyPointerEpoch, | |||
| 345 | - | }, true | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | // invalidateSnapshotCopyPointer clears only the pointer captured with the | |||
| 349 | - | // input snapshot. Mapping runs outside copyMu, so a newer press can otherwise | |||
| 350 | - | // be erased by an older rejected event. | |||
| 351 | - | func invalidateSnapshotCopyPointer(rt *overlayRuntime, snapshot copyMouseInputSnapshot) { | |||
| 352 | - | rt.copyMu.Lock() | |||
| 353 | - | defer rt.copyMu.Unlock() | |||
| 354 | - | if rt.copyPointerEpoch != snapshot.epoch || rt.copyMode != snapshot.mode || | |||
| 355 | - | rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document { | |||
| 356 | - | return | |||
| 357 | - | } | |||
| 358 | - | rt.invalidateCopyPointerLocked(true) | |||
| 359 | - | } | |||
| 360 | - | ||||
| 361 | - | // handleActiveCopyMouse keeps every drag tied to the pane/document captured at | |||
| 362 | - | // press time. Geometry is resolved after copyMu is released to preserve the | |||
| 363 | - | // daemon's copyMu -> tab/pane lock prohibition. | |||
| 364 | - | func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *tab, ev mouse.Event) { | |||
| 365 | - | rt := ac.overlays | |||
| 366 | - | snapshot, active := snapshotCopyMouseInput(rt) | |||
| 367 | - | if !active { | |||
| 368 | - | return | |||
| 369 | - | } | |||
| 370 | - | if d.beforeCopyMouseMap != nil { | |||
| 371 | - | d.beforeCopyMouseMap() | |||
| 372 | - | } | |||
| 373 | - | cfg := d.currentFloatingConfig() | |||
| 374 | - | // Once pressed, use the committed rectangle captured by that press. A | |||
| 375 | - | // later floating/layout change must not turn an in-flight drag into a hit | |||
| 376 | - | // on a different pane. | |||
| 377 | - | if ev.Type != mouse.Press && snapshot.pointer.valid && snapshot.pointer.pane == snapshot.pane && snapshot.pointer.document == snapshot.document { | |||
| 378 | - | mapped, ok := mapCopyMouse(ev, snapshot.pointer.geometry, snapshot.viewportTop, snapshot.document, ev.Type == mouse.Motion) | |||
| 379 | - | if ev.Type == mouse.Release && !ok { | |||
| 380 | - | invalidateSnapshotCopyPointer(rt, snapshot) | |||
| 381 | - | return | |||
| 382 | - | } | |||
| 383 | - | if ok { | |||
| 384 | - | d.copyMouse(sess, ac, ev, mapped, snapshot, snapshot.pointer.geometry) | |||
| 385 | - | } | |||
| 386 | - | return | |||
| 387 | - | } | |||
| 388 | - | tb.mu.Lock() | |||
| 389 | - | geometry, ok := copyMouseGeometryForPaneLocked(tb, cfg, snapshot.pane) | |||
| 390 | - | if !ok && ev.Type == mouse.Press { | |||
| 391 | - | // Preserve the established press-on-another-pane behavior: copy mode | |||
| 392 | - | // exits rather than retargeting its immutable document. | |||
| 393 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane && tb.tree != nil { | |||
| 394 | - | focusPlacementLocked(tb, hit.pane.id) | |||
| 395 | - | d.applyLayoutLocked(tb) | |||
| 396 | - | } | |||
| 397 | - | } | |||
| 398 | - | tb.mu.Unlock() | |||
| 399 | - | if !ok { | |||
| 400 | - | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |||
| 401 | - | invalidateSnapshotCopyPointer(rt, snapshot) | |||
| 402 | - | } | |||
| 403 | - | return | |||
| 404 | - | } | |||
| 405 | - | clamp := ev.Type == mouse.Motion || ev.Type == mouse.Release | |||
| 406 | - | mapped, mappedOK := mapCopyMouse(ev, geometry, snapshot.viewportTop, snapshot.document, clamp) | |||
| 407 | - | if !mappedOK { | |||
| 408 | - | if ev.Type == mouse.Press { | |||
| 409 | - | otherPane := false | |||
| 410 | - | tb.mu.Lock() | |||
| 411 | - | if hit, hitOK := hitTestCopyMouseGeometryLocked(tb, cfg, ev.Col, ev.Row); hitOK && hit.pane != snapshot.pane { | |||
| 412 | - | otherPane = true | |||
| 413 | - | if tb.tree != nil { | |||
| 414 | - | focusPlacementLocked(tb, hit.pane.id) | |||
| 415 | - | d.applyLayoutLocked(tb) | |||
| 416 | - | } | |||
| 417 | - | } | |||
| 418 | - | tb.mu.Unlock() | |||
| 419 | - | if otherPane { | |||
| 420 | - | d.exitCopyMode(ac) | |||
| 421 | - | } | |||
| 422 | - | } | |||
| 423 | - | if ev.Type == mouse.Release || ev.Type == mouse.Press { | |||
| 424 | - | invalidateSnapshotCopyPointer(rt, snapshot) | |||
| 425 | - | } | |||
| 426 | - | return | |||
| 427 | - | } | |||
| 428 | - | d.copyMouse(sess, ac, ev, mapped, snapshot, geometry) | |||
| 429 | - | } | |||
| 430 | - | ||||
| 431 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | 149 | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | ||
| 432 | if p == nil { | 150 | if p == nil { | ||
| 433 | return | 151 | return | ||