Skip to content

Commit 74ef4cd

Browse files
committed
feat: preserve scroll position on write
Change-Id: I3460285d7e278bbd0a3a02a056738ae734c8eb1a Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 6a1a50d commit 74ef4cd

3 files changed

Lines changed: 523 additions & 2 deletions

File tree

‎lib/interfaces.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface ITerminalOptions {
2121

2222
// Scrolling options
2323
smoothScrollDuration?: number; // Duration in ms for smooth scroll animation (default: 100, 0 = instant)
24+
preserveScrollOnWrite?: boolean; // Preserve scrolled-up viewport on write (default: false)
2425

2526
// Internal: Ghostty WASM instance (optional, for test isolation)
2627
// If not provided, uses the module-level instance from init()

‎lib/scrolling.test.ts‎

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
9+
import type { ITerminalOptions } from './interfaces';
910
import type { Terminal } from './terminal';
1011
import { createIsolatedTerminal } from './test-helpers';
1112

@@ -680,3 +681,382 @@ describe('Custom Wheel Event Handler', () => {
680681
expect((term as any).viewportY).toBeGreaterThan(0);
681682
});
682683
});
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() preserves viewport when a batched write reaches the scrollback cap', async () => {
869+
const { term, container } = await createPreserveScrollTestTerminal({
870+
preserveScrollOnWrite: true,
871+
scrollback: 5,
872+
});
873+
874+
const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3'].map(makeSignatureLine);
875+
const newScrollback = ['old-2', 'old-3', 'new-4', 'new-5', 'new-6'].map(makeSignatureLine);
876+
const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm);
877+
const originalGetScrollbackLength = term.getScrollbackLength.bind(term);
878+
const originalGetScrollbackLine = term.getScrollbackLine.bind(term);
879+
let afterWrite = false;
880+
881+
try {
882+
term.viewportY = 2;
883+
(term as any).targetViewportY = 2;
884+
(term as any).getScrollbackLength = () => (afterWrite ? 5 : 4);
885+
(term as any).getScrollbackLine = (offset: number) =>
886+
(afterWrite ? newScrollback : oldScrollback)[offset] ?? null;
887+
term.wasmTerm!.write = (() => {
888+
afterWrite = true;
889+
}) as typeof term.wasmTerm.write;
890+
891+
term.write('Line 004\r\nLine 005\r\nLine 006\r\n');
892+
893+
expect(term.getViewportY()).toBe(5);
894+
} finally {
895+
term.wasmTerm!.write = originalWrite;
896+
(term as any).getScrollbackLength = originalGetScrollbackLength;
897+
(term as any).getScrollbackLine = originalGetScrollbackLine;
898+
disposePreserveScrollTestTerminal(term, container);
899+
}
900+
});
901+
902+
test('write() falls back to scrollback delta when preserved anchor disappears', async () => {
903+
const { term, container } = await createPreserveScrollTestTerminal({
904+
preserveScrollOnWrite: true,
905+
});
906+
907+
const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3', 'old-4'].map(makeSignatureLine);
908+
const newScrollback = ['new-0', 'new-1', 'new-2', 'new-3', 'new-4'].map(makeSignatureLine);
909+
const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm);
910+
const originalGetScrollbackLength = term.getScrollbackLength.bind(term);
911+
const originalGetScrollbackLine = term.getScrollbackLine.bind(term);
912+
let afterWrite = false;
913+
914+
try {
915+
term.viewportY = 3;
916+
(term as any).targetViewportY = 3;
917+
(term as any).getScrollbackLength = () => 5;
918+
(term as any).getScrollbackLine = (offset: number) =>
919+
(afterWrite ? newScrollback : oldScrollback)[offset] ?? null;
920+
term.wasmTerm!.write = (() => {
921+
afterWrite = true;
922+
}) as typeof term.wasmTerm.write;
923+
924+
term.write('\x1bc');
925+
926+
expect(term.getViewportY()).toBe(3);
927+
} finally {
928+
term.wasmTerm!.write = originalWrite;
929+
(term as any).getScrollbackLength = originalGetScrollbackLength;
930+
(term as any).getScrollbackLine = originalGetScrollbackLine;
931+
disposePreserveScrollTestTerminal(term, container);
932+
}
933+
});
934+
935+
test('write() can preserve anchors shifted by a large capped write', async () => {
936+
const { term, container } = await createPreserveScrollTestTerminal({
937+
preserveScrollOnWrite: true,
938+
scrollback: 1000,
939+
});
940+
941+
const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm);
942+
const originalGetScrollbackLength = term.getScrollbackLength.bind(term);
943+
const originalGetScrollbackLine = term.getScrollbackLine.bind(term);
944+
let afterWrite = false;
945+
946+
try {
947+
term.viewportY = 500;
948+
(term as any).targetViewportY = 500;
949+
(term as any).getScrollbackLength = () => 1000;
950+
(term as any).getScrollbackLine = (offset: number) => {
951+
const shiftedOffset = afterWrite ? offset + 300 : offset;
952+
return makeSignatureLine(`line-${shiftedOffset}`);
953+
};
954+
term.wasmTerm!.write = (() => {
955+
afterWrite = true;
956+
}) as typeof term.wasmTerm.write;
957+
958+
term.write(Array.from({ length: 300 }, (_, index) => `Line ${index}\r\n`).join(''));
959+
960+
expect(term.getViewportY()).toBe(800);
961+
} finally {
962+
term.wasmTerm!.write = originalWrite;
963+
(term as any).getScrollbackLength = originalGetScrollbackLength;
964+
(term as any).getScrollbackLine = originalGetScrollbackLine;
965+
disposePreserveScrollTestTerminal(term, container);
966+
}
967+
});
968+
969+
test('write() bounds anchor search when capped scrollback anchor is not found', async () => {
970+
const { term, container } = await createPreserveScrollTestTerminal({
971+
preserveScrollOnWrite: true,
972+
scrollback: 1000,
973+
});
974+
975+
const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm);
976+
const originalGetScrollbackLength = term.getScrollbackLength.bind(term);
977+
const originalGetScrollbackLine = term.getScrollbackLine.bind(term);
978+
let afterWrite = false;
979+
let postWriteLineReads = 0;
980+
981+
try {
982+
term.viewportY = 500;
983+
(term as any).targetViewportY = 500;
984+
(term as any).getScrollbackLength = () => 1000;
985+
(term as any).getScrollbackLine = (offset: number) => {
986+
if (afterWrite) {
987+
postWriteLineReads++;
988+
}
989+
return makeSignatureLine(`${afterWrite ? 'new' : 'old'}-${offset}`);
990+
};
991+
term.wasmTerm!.write = (() => {
992+
afterWrite = true;
993+
}) as typeof term.wasmTerm.write;
994+
995+
term.write('x'.repeat(50000));
996+
997+
expect(term.getViewportY()).toBe(500);
998+
expect(postWriteLineReads).toBeLessThanOrEqual(101);
999+
} finally {
1000+
term.wasmTerm!.write = originalWrite;
1001+
(term as any).getScrollbackLength = originalGetScrollbackLength;
1002+
(term as any).getScrollbackLine = originalGetScrollbackLine;
1003+
disposePreserveScrollTestTerminal(term, container);
1004+
}
1005+
});
1006+
1007+
test('write() does not move preserved viewport when scrollback does not grow', async () => {
1008+
const { term, container } = await createPreserveScrollTestTerminal({
1009+
preserveScrollOnWrite: true,
1010+
});
1011+
1012+
try {
1013+
writeNumberedLines(term, 12);
1014+
term.scrollLines(-3);
1015+
1016+
const beforeViewportY = term.getViewportY();
1017+
const beforeScrollbackLength = term.getScrollbackLength();
1018+
const scrollEvents: number[] = [];
1019+
const scrollDisposable = term.onScroll((value) => scrollEvents.push(value));
1020+
1021+
try {
1022+
term.write('x');
1023+
1024+
expect(term.getScrollbackLength()).toBe(beforeScrollbackLength);
1025+
expect(term.getViewportY()).toBe(beforeViewportY);
1026+
expect(scrollEvents).toEqual([]);
1027+
} finally {
1028+
scrollDisposable.dispose();
1029+
}
1030+
} finally {
1031+
disposePreserveScrollTestTerminal(term, container);
1032+
}
1033+
});
1034+
1035+
test('preserveScrollOnWrite can be enabled at runtime through terminal options', async () => {
1036+
const { term, container } = await createPreserveScrollTestTerminal();
1037+
1038+
try {
1039+
writeNumberedLines(term, 12);
1040+
term.scrollLines(-3);
1041+
term.write('Default mode\r\n');
1042+
expect(term.getViewportY()).toBe(0);
1043+
1044+
term.options.preserveScrollOnWrite = true;
1045+
term.scrollLines(-3);
1046+
1047+
const beforeViewportY = term.getViewportY();
1048+
const beforeScrollbackLength = term.getScrollbackLength();
1049+
term.write('Opt-in mode\r\n');
1050+
1051+
const afterScrollbackLength = term.getScrollbackLength();
1052+
expect(term.getViewportY()).toBe(
1053+
clampViewportY(
1054+
beforeViewportY + (afterScrollbackLength - beforeScrollbackLength),
1055+
afterScrollbackLength
1056+
)
1057+
);
1058+
} finally {
1059+
disposePreserveScrollTestTerminal(term, container);
1060+
}
1061+
});
1062+
});

0 commit comments

Comments
 (0)