diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts index 72c62201..d88c8743 100644 --- a/packages/xterm-zerolag-input/src/overlay-renderer.ts +++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts @@ -65,38 +65,71 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): charTop, charHeight, promptRow, + totalRows, font, showCursor, cursorColor, terminal, } = params; - // Position container at prompt row. + // ── Keep what is being typed ON SCREEN ──────────────────────────── + // + // The overlay lays its wrapped lines out DOWNWARD from the prompt row, and + // nothing past the last terminal row is visible. On a phone the strip left + // above the on-screen keyboard is only a handful of rows, so a prompt long + // enough to wrap ran off the bottom and the user was typing blind — the tail + // of their own sentence, the part they are actually looking at, hidden behind + // the keyboard. + // + // So the composer grows UPWARD once it reaches the last row, exactly as a real + // terminal's does: every line div is opaque (see makeLine), so the lines cover + // transcript rows above instead of vanishing under the keyboard below, and the + // newest text stays where the eye is. A prompt taller than the whole viewport + // keeps its TAIL for the same reason. + // + // `startCol` indents only the line that begins at the prompt marker, so it is + // dropped along with that line when the tail is all that fits. + const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows; + let visibleLines = lines; + let keepsPromptLine = true; + let topRow = promptRow; + if (rows && rows > 0) { + if (lines.length > rows) { + visibleLines = lines.slice(lines.length - rows); + keepsPromptLine = false; + topRow = 0; + } else if (promptRow + lines.length > rows) { + topRow = rows - lines.length; + } + } + topRow = Math.max(0, topRow); + container.style.left = '0px'; - container.style.top = promptRow * cellH + 'px'; + container.style.top = topRow * cellH + 'px'; // Clear and rebuild (typically 1-3 line divs, negligible cost) container.innerHTML = ''; const fullWidthPx = totalCols * cellW; - for (let i = 0; i < lines.length; i++) { - const leftPx = i === 0 ? startCol * cellW : 0; - const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx; + for (let i = 0; i < visibleLines.length; i++) { + const indents = i === 0 && keepsPromptLine; + const leftPx = indents ? startCol * cellW : 0; + const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx; const topPx = i * cellH; - const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal); + const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal); container.appendChild(lineEl); } // Block cursor at end of last line (use visual width for CJK support) if (showCursor) { - const lastLine = lines[lines.length - 1]; - const lastLineLeft = lines.length === 1 ? startCol : 0; + const lastLine = visibleLines[visibleLines.length - 1]; + const lastLineLeft = visibleLines.length === 1 && keepsPromptLine ? startCol : 0; const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine); if (cursorCol < totalCols) { const cursor = document.createElement('span'); cursor.style.cssText = 'position:absolute;display:inline-block'; cursor.style.left = cursorCol * cellW + 'px'; - cursor.style.top = (lines.length - 1) * cellH + 'px'; + cursor.style.top = (visibleLines.length - 1) * cellH + 'px'; cursor.style.width = cellW + 'px'; cursor.style.height = cellH + 'px'; cursor.style.backgroundColor = cursorColor; diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts index dedd7ca5..bb9f7fe1 100644 --- a/packages/xterm-zerolag-input/src/types.ts +++ b/packages/xterm-zerolag-input/src/types.ts @@ -172,6 +172,13 @@ export interface RenderParams { /** Height of the character rendering area (px). */ charHeight: number; promptRow: number; + /** + * Visible terminal rows. When given, the overlay is kept ON SCREEN: it grows + * upward instead of running off the bottom edge, and a wrapped prompt taller + * than the viewport keeps its tail. Omit to lay out straight down from + * `promptRow` (the historical behaviour). + */ + totalRows?: number; font: FontStyle; showCursor: boolean; cursorColor: string; diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 5e201ba4..b1e4105b 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -565,7 +565,10 @@ export class ZerolagInputAddon implements XtermAddon { // Skip redundant re-renders — include text content to detect // same-length changes (e.g., setFlushed with different text) - const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`; + // `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}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`; if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -612,6 +615,7 @@ export class ZerolagInputAddon implements XtermAddon { charTop, charHeight, promptRow: activePrompt.row, + totalRows: this._terminal.rows, font: this._font, showCursor: this._options.showCursor, cursorColor, diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts index c87a02a7..a95846a8 100644 --- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts +++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts @@ -418,3 +418,88 @@ describe('stringCellWidth', () => { expect(stringCellWidth(null, '')).toBe(0); }); }); + +describe('renderOverlay — staying on screen (totalRows)', () => { + // A phone with the keyboard up leaves only a handful of terminal rows. The + // overlay lays its wrapped lines out downward from the prompt row, so a long + // prompt used to run off the bottom edge and the user typed blind, with the + // tail of their own sentence behind the keyboard. With totalRows known, the + // composer grows UPWARD instead — the line divs are opaque, so they cover + // transcript above rather than disappearing below. + const linesOf = (n: number) => Array.from({ length: n }, (_, i) => `line${i}`); + const lineDivs = (container: HTMLDivElement) => + Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[]; + + it('lifts the block so its last line lands on the last visible row', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: linesOf(5), promptRow: 10, totalRows: 12, cellH: 17 })); + + // 10 + 5 would end on row 14 of a 12-row screen; the block starts at 7 instead. + expect(container.style.top).toBe(7 * 17 + 'px'); + expect(lineDivs(container)).toHaveLength(5); + }); + + it('leaves the prompt row alone when the block already fits', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: linesOf(3), promptRow: 5, totalRows: 24, cellH: 17 })); + + expect(container.style.top).toBe(5 * 17 + 'px'); + }); + + it('keeps the TAIL when the prompt is taller than the whole viewport', () => { + // The end is where the cursor is, and where the user is looking. + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, cellH: 20 })); + + const divs = lineDivs(container); + expect(container.style.top).toBe('0px'); + expect(divs).toHaveLength(3); + expect(divs.map((d) => d.textContent)).toEqual(['line3', 'line4', 'line5']); + }); + + it('drops the prompt indent once the prompt line is no longer shown', () => { + // startCol indents only the line that begins at the prompt marker. + const container = document.createElement('div'); + renderOverlay( + container, + makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, startCol: 5, cellW: 10, totalCols: 80 }) + ); + + const first = lineDivs(container)[0]; + expect(first.style.left).toBe('0px'); + expect(first.style.width).toBe(80 * 10 + 'px'); + }); + + it('rides the cursor on the last VISIBLE line', () => { + const container = document.createElement('div'); + renderOverlay( + container, + makeParams({ lines: ['aaa', 'bbb', 'ccc', 'ddd'], promptRow: 9, totalRows: 3, cellH: 20, cellW: 10, startCol: 4 }) + ); + + const cursor = Array.from(container.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement; + // Tail is the last 3 lines, so the cursor sits on row 2 (0-based) of the block… + expect(cursor.style.top).toBe(2 * 20 + 'px'); + // …at column 3, NOT startCol + 3: the indented prompt line is not shown. + expect(cursor.style.left).toBe(3 * 10 + 'px'); + }); + + it('lays out straight down when totalRows is absent (unchanged behaviour)', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: linesOf(9), promptRow: 20, cellH: 17 })); + + expect(container.style.top).toBe(20 * 17 + 'px'); + expect(lineDivs(container)).toHaveLength(9); + }); + + it('falls back to the terminal row count when totalRows is not passed', () => { + // The addon passes totalRows, but a stale bundle / third-party caller may not. + const container = document.createElement('div'); + renderOverlay( + container, + makeParams({ lines: linesOf(4), promptRow: 8, cellH: 17, terminal: { rows: 10, cols: 80 } as never }) + ); + + expect(container.style.top).toBe(6 * 17 + 'px'); + }); +}); diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 201bb103..393180b3 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -573,6 +573,42 @@ const KeyboardHandler = { * space below the last row. After fitAddon.fit(), measure the gap and * reduce padding by that amount so the terminal sits flush against the bars. */ + /** + * Combined height of the fixed bars that overlay the terminal's bottom edge. + * + * On phones the toolbar and the accessory bar are `position: fixed`, so they + * occupy no layout space of their own — `main`'s padding-bottom is the only + * thing reserving room for them, and any pixel taken out of it is a pixel of + * terminal painted underneath them. + */ + _fixedBottomBarsHeight() { + let px = 0; + for (const selector of ['.toolbar', '.keyboard-accessory-bar', '#cjkInput.cjk-input-visible']) { + const el = document.querySelector(selector); + if (!el) continue; + const style = window.getComputedStyle?.(el); + if (style && (style.display === 'none' || style.visibility === 'hidden')) continue; + px += el.offsetHeight || 0; + } + return px; + }, + + /** + * Reclaim sub-row slack at the bottom of the terminal — but never the space the + * fixed bars stand in. + * + * Shrinking the padding by the whole slack pulled the terminal's bottom edge + * DOWN under those bars, and the row the following re-fit then gained was + * painted behind them: on a long wrapped prompt the last line was clipped by + * the accessory bar, i.e. the bottom half of the text being typed. The floor is + * now the bars' MEASURED height, so a device where the hard-coded 84px + * over-reserves still reclaims the difference, while one that genuinely needs + * it keeps every pixel. + * + * ⚠️ The floor can only ever prevent a shrink, never cause a grow + * (`Math.min(currentPadding, …)`): a measured height LARGER than the current + * padding makes this a no-op rather than silently resizing the terminal. + */ _shrinkPaddingToFit() { try { const container = document.getElementById('terminalContainer'); @@ -583,7 +619,8 @@ const KeyboardHandler = { const gap = container.clientHeight - app.terminal.rows * cellH; if (gap > 0 && gap < cellH) { const currentPadding = parseInt(main.style.paddingBottom) || 0; - main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px'; + const floor = Math.min(currentPadding, this._fixedBottomBarsHeight()); + main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px'; if (app.fitAddon) try { app.fitAddon.fit(); diff --git a/test/mobile-keyboard-bottom-padding.test.ts b/test/mobile-keyboard-bottom-padding.test.ts new file mode 100644 index 00000000..75e96051 --- /dev/null +++ b/test/mobile-keyboard-bottom-padding.test.ts @@ -0,0 +1,178 @@ +// Port: none (pure logic in a vm context — no browser, no server). +// +// On phones the toolbar and the keyboard accessory bar are `position: fixed`, so +// they take no layout space: `main`'s padding-bottom is the ONLY thing reserving +// room for them, and every pixel taken out of it is a pixel of terminal painted +// underneath them. +// +// `_shrinkPaddingToFit` reclaims the sub-row slack left after a keyboard-driven +// re-fit. It used to take the whole slack, which pulled the terminal's bottom edge +// down under those bars — and the row the following re-fit gained was painted +// behind them, clipping the last line of a long wrapped prompt: the bottom half of +// the text being typed. The floor is now the bars' MEASURED height. +// +// Lives outside test/mobile/ deliberately — that suite is Playwright-driven and +// excluded from `npm run test:ci`, so a regression guarded only there is invisible +// to CI (same reasoning as terminal-scroll-intent.test.ts). +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8'); + +interface Bar { + offsetHeight: number; + hidden?: boolean; +} + +interface Setup { + paddingBottom: string; + containerHeight: number; + rows: number; + cellH: number; + bars: Partial>; +} + +/** + * Load mobile-handlers.js and hand back its KeyboardHandler plus the fake `main` + * whose inline padding the function edits. + * + * `const KeyboardHandler = {...}` is a lexical binding that does not survive to a + * second `vm.runInContext`, so the export is appended to the SAME script. + */ +function loadHandler(setup: Setup) { + const main = { style: { paddingBottom: setup.paddingBottom } }; + const container = { clientHeight: setup.containerHeight }; + let fits = 0; + const app = { + terminal: { + rows: setup.rows, + _core: { _renderService: { dimensions: { css: { cell: { height: setup.cellH } } } } }, + }, + fitAddon: { + fit: () => { + fits++; + }, + }, + }; + const context = vm.createContext({ + console, + app, + navigator: { userAgent: 'test', maxTouchPoints: 1 }, + window: { + addEventListener: () => {}, + matchMedia: () => ({ matches: false }), + scrollTo: () => {}, + getComputedStyle: (el: Bar) => ({ display: el.hidden ? 'none' : 'block', visibility: 'visible' }), + }, + document: { + body: { classList: { add: () => {}, remove: () => {} } }, + addEventListener: () => {}, + getElementById: (id: string) => (id === 'terminalContainer' ? container : null), + querySelector: (sel: string) => + sel === '.main' ? main : (setup.bars as Record)[sel] || null, + }, + setTimeout: () => 1, + clearTimeout: () => {}, + }); + vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' }); + return { handler: (context as { __KH: any }).__KH, main, fits: () => fits }; +} + +// 10 rows × 19px = 190 in a 200px container → 10px of slack, less than one row. +const BASE: Setup = { + paddingBottom: '84px', + containerHeight: 200, + rows: 10, + cellH: 19, + bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44 } }, +}; + +describe('_shrinkPaddingToFit', () => { + it('reclaims the slack when the reservation over-reserves', () => { + // Bars really need 60px, 84 is reserved → the 10px of slack is free to take. + const { handler, main } = loadHandler({ + ...BASE, + bars: { '.toolbar': { offsetHeight: 30 }, '.keyboard-accessory-bar': { offsetHeight: 30 } }, + }); + + handler._shrinkPaddingToFit(); + + expect(main.style.paddingBottom).toBe('74px'); + }); + + it('never shrinks into the space the bars actually occupy', () => { + // 40 + 44 = 84: the reservation is exactly right, so there is nothing to take + // even though the terminal has 10px of slack. + const { handler, main } = loadHandler(BASE); + + handler._shrinkPaddingToFit(); + + expect(main.style.paddingBottom).toBe('84px'); + }); + + it('stops part-way when only some of the slack is free', () => { + // Bars need 78px of the reserved 84 → 6px may be reclaimed, not the full 10. + const { handler, main } = loadHandler({ + ...BASE, + bars: { '.toolbar': { offsetHeight: 34 }, '.keyboard-accessory-bar': { offsetHeight: 44 } }, + }); + + handler._shrinkPaddingToFit(); + + expect(main.style.paddingBottom).toBe('78px'); + }); + + it('is a no-op, never a grow, when the bars are taller than the reservation', () => { + // Growing the padding here would resize the terminal as a side effect of a + // function that exists to reclaim slack. + const { handler, main } = loadHandler({ + ...BASE, + bars: { '.toolbar': { offsetHeight: 60 }, '.keyboard-accessory-bar': { offsetHeight: 60 } }, + }); + + handler._shrinkPaddingToFit(); + + expect(main.style.paddingBottom).toBe('84px'); + }); + + it('does not count a hidden bar', () => { + // The accessory bar is display:none until the keyboard opens; counting it + // would block a reclaim that is genuinely free. + const { handler, main } = loadHandler({ + ...BASE, + bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44, hidden: true } }, + }); + + handler._shrinkPaddingToFit(); + + expect(main.style.paddingBottom).toBe('74px'); + }); + + it('counts the CJK input strip when it is on screen', () => { + const { handler, main } = loadHandler({ + ...BASE, + bars: { + '.toolbar': { offsetHeight: 30 }, + '.keyboard-accessory-bar': { offsetHeight: 30 }, + '#cjkInput.cjk-input-visible': { offsetHeight: 20 }, + }, + }); + + handler._shrinkPaddingToFit(); + + expect(main.style.paddingBottom).toBe('80px'); + }); + + it('leaves the padding alone when the slack is a whole row or more', () => { + // A full row of slack means the re-fit will claim it as a row; padding is not + // the lever here. + const { handler, main, fits } = loadHandler({ ...BASE, containerHeight: 190 + 19 }); + + handler._shrinkPaddingToFit(); + + expect(main.style.paddingBottom).toBe('84px'); + expect(fits()).toBe(0); + }); +});