mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
178 lines
6.2 KiB
TypeScript
178 lines
6.2 KiB
TypeScript
// 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<Record<'.toolbar' | '.keyboard-accessory-bar' | '#cjkInput.cjk-input-visible', Bar>>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 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<string, Bar | undefined>)[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);
|
|||
|
|
});
|
|||
|
|
});
|