Skip to content

Commit bf992cf

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

3 files changed

Lines changed: 387 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: 274 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,276 @@ 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() 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

Comments
 (0)