( s: SelectionState, dRow: number, minRow: number, maxRow: number, width: number, )
| 468 | * scroll can jump either way. |
| 469 | */ |
| 470 | export function shiftSelection( |
| 471 | s: SelectionState, |
| 472 | dRow: number, |
| 473 | minRow: number, |
| 474 | maxRow: number, |
| 475 | width: number, |
| 476 | ): void { |
| 477 | if (!s.anchor || !s.focus) return |
| 478 | // Virtual rows track pre-clamp positions so reverse scrolls restore |
| 479 | // correctly. Without this, clamp(5→0) + shift(+10) = 10, not the true 5, |
| 480 | // and scrolledOffAbove stays stale (highlight ≠ copy). |
| 481 | const vAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow |
| 482 | const vFocus = (s.virtualFocusRow ?? s.focus.row) + dRow |
| 483 | if ( |
| 484 | (vAnchor < minRow && vFocus < minRow) || |
| 485 | (vAnchor > maxRow && vFocus > maxRow) |
| 486 | ) { |
| 487 | clearSelection(s) |
| 488 | return |
| 489 | } |
| 490 | // Debt = how far the nearer endpoint overshoots each edge. When debt |
| 491 | // shrinks (reverse scroll), those rows are back on-screen — pop from |
| 492 | // the accumulator so getSelectedText doesn't double-count them. |
| 493 | const oldMin = Math.min( |
| 494 | s.virtualAnchorRow ?? s.anchor.row, |
| 495 | s.virtualFocusRow ?? s.focus.row, |
| 496 | ) |
| 497 | const oldMax = Math.max( |
| 498 | s.virtualAnchorRow ?? s.anchor.row, |
| 499 | s.virtualFocusRow ?? s.focus.row, |
| 500 | ) |
| 501 | const oldAboveDebt = Math.max(0, minRow - oldMin) |
| 502 | const oldBelowDebt = Math.max(0, oldMax - maxRow) |
| 503 | const newAboveDebt = Math.max(0, minRow - Math.min(vAnchor, vFocus)) |
| 504 | const newBelowDebt = Math.max(0, Math.max(vAnchor, vFocus) - maxRow) |
| 505 | if (newAboveDebt < oldAboveDebt) { |
| 506 | // scrolledOffAbove pushes newest at the end (closest to on-screen). |
| 507 | const drop = oldAboveDebt - newAboveDebt |
| 508 | s.scrolledOffAbove.length -= drop |
| 509 | s.scrolledOffAboveSW.length = s.scrolledOffAbove.length |
| 510 | } |
| 511 | if (newBelowDebt < oldBelowDebt) { |
| 512 | // scrolledOffBelow unshifts newest at the front (closest to on-screen). |
| 513 | const drop = oldBelowDebt - newBelowDebt |
| 514 | s.scrolledOffBelow.splice(0, drop) |
| 515 | s.scrolledOffBelowSW.splice(0, drop) |
| 516 | } |
| 517 | // Invariant: accumulator length ≤ debt. If the accumulator exceeds debt, |
| 518 | // the excess is stale — e.g., moveFocus cleared virtualFocusRow without |
| 519 | // trimming the accumulator, orphaning entries the pop above can never |
| 520 | // reach because oldDebt was ALREADY 0. Truncate to debt (keeping the |
| 521 | // newest = closest-to-on-screen entries). Check newDebt (not oldDebt): |
| 522 | // captureScrolledRows runs BEFORE this shift in the real flow (ink.tsx), |
| 523 | // so at entry the accumulator is populated but oldDebt is still 0 — |
| 524 | // that's the normal establish-debt path, not stale. |
| 525 | if (s.scrolledOffAbove.length > newAboveDebt) { |
| 526 | // Above pushes newest at END → keep END. |
| 527 | s.scrolledOffAbove = |
no test coverage detected