()
| 655 | } |
| 656 | } |
| 657 | function tick(): void { |
| 658 | const sel = selection.getState(); |
| 659 | const s = scrollRef.current; |
| 660 | const dir = dirRef.current; |
| 661 | // dir === 0 defends against a stale interval (start() may have set one |
| 662 | // after the immediate tick already called stop() at a scroll boundary). |
| 663 | // ticks cap defends against a lost release event (mouse released |
| 664 | // outside terminal window) leaving isDragging stuck true. |
| 665 | if (!sel?.isDragging || !sel.focus || !s || dir === 0 || ++ticksRef.current > AUTOSCROLL_MAX_TICKS) { |
| 666 | stop(); |
| 667 | return; |
| 668 | } |
| 669 | // scrollBy accumulates into pendingScrollDelta; the screen buffer |
| 670 | // doesn't update until the next render drains it. If a previous |
| 671 | // tick's scroll hasn't drained yet, captureScrolledRows would read |
| 672 | // stale content (same rows as last tick → duplicated in the |
| 673 | // accumulator AND missing the rows that actually scrolled out). |
| 674 | // Skip this tick; the 50ms interval will retry after Ink's 16ms |
| 675 | // render catches up. Also prevents shiftAnchor from desyncing. |
| 676 | if (s.getPendingDelta() !== 0) return; |
| 677 | const top = s.getViewportTop(); |
| 678 | const bottom = top + s.getViewportHeight() - 1; |
| 679 | // Clamp anchor within [top, bottom]. Not [0, bottom]: the ScrollBox |
| 680 | // padding row at 0 would produce a blank line between scrolledOffAbove |
| 681 | // and the on-screen content in getSelectedText. The padding-row |
| 682 | // highlight was a minor visual nicety; text correctness wins. |
| 683 | if (dir < 0) { |
| 684 | if (s.getScrollTop() <= 0) { |
| 685 | stop(); |
| 686 | return; |
| 687 | } |
| 688 | // Scrolling up: content moves down in viewport, so anchor row +N. |
| 689 | // Clamp to actual scroll distance so anchor stays in sync when near |
| 690 | // the top boundary (renderer clamps scrollTop to 0 on drain). |
| 691 | const actual = Math.min(AUTOSCROLL_LINES, s.getScrollTop()); |
| 692 | // Capture rows about to scroll out the BOTTOM before scrollBy |
| 693 | // overwrites them. Only rows inside the selection are captured |
| 694 | // (captureScrolledRows intersects with selection bounds). |
| 695 | selection.captureScrolledRows(bottom - actual + 1, bottom, 'below'); |
| 696 | selection.shiftAnchor(actual, 0, bottom); |
| 697 | s.scrollBy(-AUTOSCROLL_LINES); |
| 698 | } else { |
| 699 | const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); |
| 700 | if (s.getScrollTop() >= max) { |
| 701 | stop(); |
| 702 | return; |
| 703 | } |
| 704 | // Scrolling down: content moves up in viewport, so anchor row -N. |
| 705 | // Clamp to actual scroll distance so anchor stays in sync when near |
| 706 | // the bottom boundary (renderer clamps scrollTop to max on drain). |
| 707 | const actual_0 = Math.min(AUTOSCROLL_LINES, max - s.getScrollTop()); |
| 708 | // Capture rows about to scroll out the TOP. |
| 709 | selection.captureScrolledRows(top, top + actual_0 - 1, 'above'); |
| 710 | selection.shiftAnchor(-actual_0, top, bottom); |
| 711 | s.scrollBy(AUTOSCROLL_LINES); |
| 712 | } |
| 713 | onScrollRef.current?.(false, s); |
| 714 | } |
no test coverage detected