|
6 | 6 | */ |
7 | 7 |
|
8 | 8 | import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; |
| 9 | +import type { ITerminalOptions } from './interfaces'; |
9 | 10 | import type { Terminal } from './terminal'; |
10 | 11 | import { createIsolatedTerminal } from './test-helpers'; |
11 | 12 |
|
@@ -680,3 +681,276 @@ describe('Custom Wheel Event Handler', () => { |
680 | 681 | expect((term as any).viewportY).toBeGreaterThan(0); |
681 | 682 | }); |
682 | 683 | }); |
| 684 | + |
| 685 | +type ScrollTestOptions = Omit<ITerminalOptions, 'ghostty'>; |
| 686 | + |
| 687 | +async function createPreserveScrollTestTerminal( |
| 688 | + options: ScrollTestOptions = {} |
| 689 | +): Promise<{ term: Terminal; container: HTMLDivElement }> { |
| 690 | + const container = document.createElement('div'); |
| 691 | + document.body.appendChild(container); |
| 692 | + const term = await createIsolatedTerminal({ |
| 693 | + cols: 20, |
| 694 | + rows: 5, |
| 695 | + scrollback: 100, |
| 696 | + smoothScrollDuration: 0, |
| 697 | + ...options, |
| 698 | + }); |
| 699 | + term.open(container); |
| 700 | + return { term, container }; |
| 701 | +} |
| 702 | + |
| 703 | +function disposePreserveScrollTestTerminal(term: Terminal, container: HTMLDivElement): void { |
| 704 | + term.dispose(); |
| 705 | + document.body.removeChild(container); |
| 706 | +} |
| 707 | + |
| 708 | +function writeNumberedLines(term: Terminal, count: number, start = 0): void { |
| 709 | + for (let i = start; i < start + count; i++) { |
| 710 | + term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); |
| 711 | + } |
| 712 | +} |
| 713 | + |
| 714 | +function cellsToText(cells: Array<{ codepoint: number }> | null | undefined): string { |
| 715 | + if (!cells) return ''; |
| 716 | + |
| 717 | + return cells |
| 718 | + .map((cell) => { |
| 719 | + const codepoint = cell.codepoint; |
| 720 | + if (codepoint <= 0 || codepoint > 0x10ffff) return ''; |
| 721 | + if (codepoint >= 0xd800 && codepoint <= 0xdfff) return ''; |
| 722 | + return String.fromCodePoint(codepoint); |
| 723 | + }) |
| 724 | + .join('') |
| 725 | + .trimEnd(); |
| 726 | +} |
| 727 | + |
| 728 | +function getVisibleLineText(term: Terminal, row: number): string { |
| 729 | + if (!term.wasmTerm) { |
| 730 | + throw new Error('Terminal must be open before reading visible lines'); |
| 731 | + } |
| 732 | + |
| 733 | + const viewportY = Math.max(0, Math.floor(term.getViewportY())); |
| 734 | + const scrollbackLength = term.getScrollbackLength(); |
| 735 | + |
| 736 | + if (viewportY > 0 && row < viewportY) { |
| 737 | + const scrollbackOffset = scrollbackLength - viewportY + row; |
| 738 | + return cellsToText(term.getScrollbackLine(scrollbackOffset)); |
| 739 | + } |
| 740 | + |
| 741 | + const screenRow = viewportY > 0 ? row - viewportY : row; |
| 742 | + return cellsToText(term.wasmTerm.getLine(screenRow)); |
| 743 | +} |
| 744 | + |
| 745 | +function clampViewportY(viewportY: number, scrollbackLength: number): number { |
| 746 | + return Math.max(0, Math.min(viewportY, scrollbackLength)); |
| 747 | +} |
| 748 | + |
| 749 | +function makeSignatureLine( |
| 750 | + text: string |
| 751 | +): Array<{ codepoint: number; flags: number; width: number }> { |
| 752 | + return Array.from({ length: 20 }, (_, index) => ({ |
| 753 | + codepoint: index < text.length ? text.codePointAt(index)! : 0, |
| 754 | + flags: 0, |
| 755 | + width: 1, |
| 756 | + })); |
| 757 | +} |
| 758 | + |
| 759 | +describe('preserveScrollOnWrite', () => { |
| 760 | + test('write() scrolls to bottom by default when viewport is scrolled up', async () => { |
| 761 | + const { term, container } = await createPreserveScrollTestTerminal(); |
| 762 | + |
| 763 | + try { |
| 764 | + expect(term.options.preserveScrollOnWrite).toBe(false); |
| 765 | + |
| 766 | + writeNumberedLines(term, 12); |
| 767 | + expect(term.getScrollbackLength()).toBeGreaterThanOrEqual(3); |
| 768 | + |
| 769 | + term.scrollLines(-3); |
| 770 | + expect(term.getViewportY()).toBe(3); |
| 771 | + |
| 772 | + term.write('Line 012\r\n'); |
| 773 | + |
| 774 | + expect(term.getViewportY()).toBe(0); |
| 775 | + } finally { |
| 776 | + disposePreserveScrollTestTerminal(term, container); |
| 777 | + } |
| 778 | + }); |
| 779 | + |
| 780 | + test('write() preserves scrolled-up viewport on opt-in and emits onScroll', async () => { |
| 781 | + const { term, container } = await createPreserveScrollTestTerminal({ |
| 782 | + preserveScrollOnWrite: true, |
| 783 | + }); |
| 784 | + |
| 785 | + try { |
| 786 | + writeNumberedLines(term, 12); |
| 787 | + term.scrollLines(-3); |
| 788 | + |
| 789 | + const beforeViewportY = term.getViewportY(); |
| 790 | + const beforeScrollbackLength = term.getScrollbackLength(); |
| 791 | + const beforeTopLine = getVisibleLineText(term, 0); |
| 792 | + const scrollEvents: number[] = []; |
| 793 | + const scrollDisposable = term.onScroll((value) => scrollEvents.push(value)); |
| 794 | + |
| 795 | + try { |
| 796 | + term.write('Line 012\r\n'); |
| 797 | + |
| 798 | + const afterScrollbackLength = term.getScrollbackLength(); |
| 799 | + const expectedViewportY = clampViewportY( |
| 800 | + beforeViewportY + (afterScrollbackLength - beforeScrollbackLength), |
| 801 | + afterScrollbackLength |
| 802 | + ); |
| 803 | + |
| 804 | + expect(afterScrollbackLength).toBeGreaterThan(beforeScrollbackLength); |
| 805 | + expect(term.getViewportY()).toBe(expectedViewportY); |
| 806 | + expect(getVisibleLineText(term, 0)).toBe(beforeTopLine); |
| 807 | + expect(scrollEvents.at(-1)).toBe(Math.floor(term.getViewportY())); |
| 808 | + } finally { |
| 809 | + scrollDisposable.dispose(); |
| 810 | + } |
| 811 | + } finally { |
| 812 | + disposePreserveScrollTestTerminal(term, container); |
| 813 | + } |
| 814 | + }); |
| 815 | + |
| 816 | + test('write() scrolls to bottom instead of preserving when entering alternate screen', async () => { |
| 817 | + const { term, container } = await createPreserveScrollTestTerminal({ |
| 818 | + preserveScrollOnWrite: true, |
| 819 | + }); |
| 820 | + |
| 821 | + try { |
| 822 | + writeNumberedLines(term, 12); |
| 823 | + term.scrollLines(-3); |
| 824 | + expect(term.getViewportY()).toBe(3); |
| 825 | + |
| 826 | + term.write('\x1b[?1049h'); |
| 827 | + |
| 828 | + expect(term.wasmTerm?.isAlternateScreen()).toBe(true); |
| 829 | + expect(term.getViewportY()).toBe(0); |
| 830 | + } finally { |
| 831 | + disposePreserveScrollTestTerminal(term, container); |
| 832 | + } |
| 833 | + }); |
| 834 | + |
| 835 | + test('write() preserves viewport when capped scrollback evicts old rows', async () => { |
| 836 | + const { term, container } = await createPreserveScrollTestTerminal({ |
| 837 | + preserveScrollOnWrite: true, |
| 838 | + }); |
| 839 | + |
| 840 | + const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3', 'old-4'].map(makeSignatureLine); |
| 841 | + const newScrollback = ['old-1', 'old-2', 'old-3', 'old-4', 'new-5'].map(makeSignatureLine); |
| 842 | + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); |
| 843 | + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); |
| 844 | + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); |
| 845 | + let afterWrite = false; |
| 846 | + |
| 847 | + try { |
| 848 | + term.viewportY = 3; |
| 849 | + (term as any).targetViewportY = 3; |
| 850 | + (term as any).getScrollbackLength = () => 5; |
| 851 | + (term as any).getScrollbackLine = (offset: number) => |
| 852 | + (afterWrite ? newScrollback : oldScrollback)[offset] ?? null; |
| 853 | + term.wasmTerm!.write = (() => { |
| 854 | + afterWrite = true; |
| 855 | + }) as typeof term.wasmTerm.write; |
| 856 | + |
| 857 | + term.write('Line 005\r\n'); |
| 858 | + |
| 859 | + expect(term.getViewportY()).toBe(4); |
| 860 | + } finally { |
| 861 | + term.wasmTerm!.write = originalWrite; |
| 862 | + (term as any).getScrollbackLength = originalGetScrollbackLength; |
| 863 | + (term as any).getScrollbackLine = originalGetScrollbackLine; |
| 864 | + disposePreserveScrollTestTerminal(term, container); |
| 865 | + } |
| 866 | + }); |
| 867 | + |
| 868 | + test('write() falls back to scrollback delta when preserved anchor disappears', async () => { |
| 869 | + const { term, container } = await createPreserveScrollTestTerminal({ |
| 870 | + preserveScrollOnWrite: true, |
| 871 | + }); |
| 872 | + |
| 873 | + const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3', 'old-4'].map(makeSignatureLine); |
| 874 | + const newScrollback = ['new-0', 'new-1', 'new-2', 'new-3', 'new-4'].map(makeSignatureLine); |
| 875 | + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); |
| 876 | + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); |
| 877 | + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); |
| 878 | + let afterWrite = false; |
| 879 | + |
| 880 | + try { |
| 881 | + term.viewportY = 3; |
| 882 | + (term as any).targetViewportY = 3; |
| 883 | + (term as any).getScrollbackLength = () => 5; |
| 884 | + (term as any).getScrollbackLine = (offset: number) => |
| 885 | + (afterWrite ? newScrollback : oldScrollback)[offset] ?? null; |
| 886 | + term.wasmTerm!.write = (() => { |
| 887 | + afterWrite = true; |
| 888 | + }) as typeof term.wasmTerm.write; |
| 889 | + |
| 890 | + term.write('\x1bc'); |
| 891 | + |
| 892 | + expect(term.getViewportY()).toBe(3); |
| 893 | + } finally { |
| 894 | + term.wasmTerm!.write = originalWrite; |
| 895 | + (term as any).getScrollbackLength = originalGetScrollbackLength; |
| 896 | + (term as any).getScrollbackLine = originalGetScrollbackLine; |
| 897 | + disposePreserveScrollTestTerminal(term, container); |
| 898 | + } |
| 899 | + }); |
| 900 | + |
| 901 | + test('write() does not move preserved viewport when scrollback does not grow', async () => { |
| 902 | + const { term, container } = await createPreserveScrollTestTerminal({ |
| 903 | + preserveScrollOnWrite: true, |
| 904 | + }); |
| 905 | + |
| 906 | + try { |
| 907 | + writeNumberedLines(term, 12); |
| 908 | + term.scrollLines(-3); |
| 909 | + |
| 910 | + const beforeViewportY = term.getViewportY(); |
| 911 | + const beforeScrollbackLength = term.getScrollbackLength(); |
| 912 | + const scrollEvents: number[] = []; |
| 913 | + const scrollDisposable = term.onScroll((value) => scrollEvents.push(value)); |
| 914 | + |
| 915 | + try { |
| 916 | + term.write('x'); |
| 917 | + |
| 918 | + expect(term.getScrollbackLength()).toBe(beforeScrollbackLength); |
| 919 | + expect(term.getViewportY()).toBe(beforeViewportY); |
| 920 | + expect(scrollEvents).toEqual([]); |
| 921 | + } finally { |
| 922 | + scrollDisposable.dispose(); |
| 923 | + } |
| 924 | + } finally { |
| 925 | + disposePreserveScrollTestTerminal(term, container); |
| 926 | + } |
| 927 | + }); |
| 928 | + |
| 929 | + test('preserveScrollOnWrite can be enabled at runtime through terminal options', async () => { |
| 930 | + const { term, container } = await createPreserveScrollTestTerminal(); |
| 931 | + |
| 932 | + try { |
| 933 | + writeNumberedLines(term, 12); |
| 934 | + term.scrollLines(-3); |
| 935 | + term.write('Default mode\r\n'); |
| 936 | + expect(term.getViewportY()).toBe(0); |
| 937 | + |
| 938 | + term.options.preserveScrollOnWrite = true; |
| 939 | + term.scrollLines(-3); |
| 940 | + |
| 941 | + const beforeViewportY = term.getViewportY(); |
| 942 | + const beforeScrollbackLength = term.getScrollbackLength(); |
| 943 | + term.write('Opt-in mode\r\n'); |
| 944 | + |
| 945 | + const afterScrollbackLength = term.getScrollbackLength(); |
| 946 | + expect(term.getViewportY()).toBe( |
| 947 | + clampViewportY( |
| 948 | + beforeViewportY + (afterScrollbackLength - beforeScrollbackLength), |
| 949 | + afterScrollbackLength |
| 950 | + ) |
| 951 | + ); |
| 952 | + } finally { |
| 953 | + disposePreserveScrollTestTerminal(term, container); |
| 954 | + } |
| 955 | + }); |
| 956 | +}); |
0 commit comments