Merge pull request #711: fix(mobile): hold the PTY rows while the on-screen keyboard is up

This commit is contained in:
Codeman maintainer committed 2026-10-27 02:24:17 +01:00
commit 98526527c8
26 files changed
+1227 -15

No files matched your search

+1
View File
@@ -206,6 +206,7 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not**
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. |
| `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). |
| `setRowLimit(limit)` | `void` | Narrow the row bound the overlay keeps itself on screen with (normally `terminal.rows`) to the first `limit` screen rows, so it grows upward from the last of them; `null` restores all rows, and a limit above `terminal.rows` changes nothing. For hosts that keep the terminal taller than the box showing it, e.g. while an on-screen keyboard covers the bottom. |
### Backspace handling
@@ -69,6 +69,9 @@ export class ZerolagInputAddon implements XtermAddon {
private _bufferDetectDone = false;
// IME text still being composed: drawn after the pending text, never sent.
private _composition = '';
// Rows from the top of the screen that are actually visible (see setRowLimit);
// null means all of them.
private _rowLimit: number | null = null;
// Render cache
private _lastRenderKey = '';
@@ -329,6 +332,35 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
/**
* Bound the layout to the first `limit` rows of the screen, or to all of them
* with `null`. It narrows the bound the overlay already keeps itself on
* screen with (`totalRows`, see _layoutRows), never adds a second one.
*
* For a host that keeps the terminal taller than the box showing it and
* scrolls the screen element instead (e.g. holding the PTY's rows while an
* on-screen keyboard covers the bottom): the composer must grow upward from
* the last row the user can SEE, not from the terminal's last row, which is
* under the keyboard.
*/
setRowLimit(limit: number | null): void {
const next = limit !== null && Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : null;
if (next === this._rowLimit) return;
this._rowLimit = next;
this.rerender();
}
/**
* The `totalRows` bound handed to renderOverlay: the one place that decides
* where the block stops growing downward. The terminal's rows, narrowed by
* setRowLimit when the host shows fewer; a limit above the terminal's rows
* changes nothing.
*/
private _layoutRows(): number {
const rows = this._terminal!.rows;
return this._rowLimit !== null ? Math.min(this._rowLimit, rows) : rows;
}
/**
* Re-read font properties from the terminal and re-render.
* Call after font size changes, theme changes, etc.
@@ -621,9 +653,11 @@ export class ZerolagInputAddon implements XtermAddon {
// Skip redundant re-renders — include text content to detect
// same-length changes (e.g., setFlushed with different text)
// `rows` is part of the key: the layout is clamped to the visible rows
// (see renderOverlay), so a keyboard opening — which changes rows without
// changing the text — must not be skipped as a redundant render.
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
// (see renderOverlay), so a keyboard opening, which changes rows or the
// row limit without changing the text, must not be skipped as a
// redundant render.
const totalRows = this._layoutRows();
const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${totalRows}:${this._flushedOffset}`;
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
@@ -671,7 +705,7 @@ export class ZerolagInputAddon implements XtermAddon {
charTop,
charHeight,
promptRow: activePrompt.row,
totalRows: this._terminal.rows,
totalRows,
font: this._font,
showCursor: this._options.showCursor,
cursorColor,
@@ -756,3 +756,78 @@ describe('viewport scrolled away from the bottom', () => {
expect(overlay.style.display).toBe('none');
});
});
describe('setRowLimit', () => {
function rowLimitSetup(promptRow = 10) {
const lines = Array.from({ length: 24 }, (_, i) => (i === promptRow ? '$ ' : ''));
const mock = createMockTerminal({ buffer: { lines }, cols: 20, rows: 24, cellHeight: 17 });
const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
mock.terminal.loadAddon(addon);
cleanups.push(() => {
addon.dispose();
mock.cleanup();
});
const overlay = () => mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
return { addon, overlay };
}
it('grows the composer upward from the last VISIBLE row, not the terminal last row', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50)); // 3 lines at 20 cols from the prompt on row 10
expect(overlay().style.top).toBe(`${10 * 17}px`);
addon.setRowLimit(12);
expect(overlay().style.top).toBe(`${9 * 17}px`);
});
it('null restores the full terminal height, and junk reads as null', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50));
addon.setRowLimit(12);
addon.setRowLimit(null);
expect(overlay().style.top).toBe(`${10 * 17}px`);
addon.setRowLimit(12);
addon.setRowLimit(Number.NaN);
expect(overlay().style.top).toBe(`${10 * 17}px`);
});
// setRowLimit narrows the SAME bound renderOverlay already keeps the block on
// screen with (RenderParams.totalRows, filled from terminal.rows), so there is
// one place that decides where the block stops growing downward.
it('narrows the terminal-rows bound and never widens it', () => {
const { addon, overlay } = rowLimitSetup(22);
addon.appendText('x'.repeat(50)); // 3 lines from row 22 of 24: the terminal bound lifts it to row 21
expect(overlay().style.top).toBe(`${21 * 17}px`);
addon.setRowLimit(24);
expect(overlay().style.top).toBe(`${21 * 17}px`);
addon.setRowLimit(100);
expect(overlay().style.top).toBe(`${21 * 17}px`);
addon.setRowLimit(23);
expect(overlay().style.top).toBe(`${20 * 17}px`);
});
it('leaves a block that fits above the limit where it is', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50));
addon.setRowLimit(13); // rows 10..12 are exactly the three lines
expect(overlay().style.top).toBe(`${10 * 17}px`);
});
it('keeps the TAIL of a block taller than the limit', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50)); // lines of 18, 20 and 12 characters
addon.setRowLimit(2);
expect(overlay().style.top).toBe('0px');
const xs = [...overlay().querySelectorAll(':scope > div')].map(
(line) => (line.textContent ?? '').replace(/[^x]/g, '').length
);
expect(xs).toEqual([20, 12]);
});
it('holds across later keystrokes, not only the re-render it triggers', () => {
const { addon, overlay } = rowLimitSetup();
addon.appendText('x'.repeat(50));
addon.setRowLimit(12);
addon.appendText('y');
expect(overlay().style.top).toBe(`${9 * 17}px`);
});
});