Merge pull request #279 from Lint111/feat/mobile-keyboard-dismiss

This commit is contained in:
Ark0N
2026-08-11 19:33:33 +02:00
committed by GitHub
2 changed files with 177 additions and 0 deletions
+90
View File
@@ -32,6 +32,25 @@
// short window, only the app's synthetic tap-to-position mouse event should
// reach xterm.
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
// Regions where a tap must NOT dismiss the on-screen keyboard
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
// Finger travel (px) still counted as a tap for keyboard dismissal. Matches
// the terminal's own TAP_THRESHOLD so both agree on tap-vs-scroll.
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
'select',
'button',
'a[href]',
'[contenteditable=""]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
'.keyboard-accessory-bar',
'.path-picker-overlay',
].join(',');
// Escape sequences occupy no terminal cells, so they must come out before a
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
// CSI, charset designators and the short escapes tmux emits; deliberately
@@ -182,6 +201,8 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
@@ -786,6 +807,8 @@ Object.assign(CodemanApp.prototype, {
// Hand-encode the SGR report for plain left-clicks on those sessions.
container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev));
this._installMobileKeyboardDismiss();
// Welcome message
this.showWelcome();
@@ -3491,6 +3514,73 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Tapping outside the terminal closes the on-screen keyboard.
*
* The terminal keeps focus on a hidden textarea, and nothing ever released it:
* once the keyboard was up, every tap on the header, the tab strip or empty
* page chrome left it up, covering half a phone screen with no way to dismiss
* it but the OS back gesture.
*
* Deliberately narrow, because focus is not ours to steal:
*
* - only when the terminal input actually holds focus;
* - never for a tap inside the terminal — those are classified and routed by
* `_handleMobileTerminalTap`, which owns that decision;
* - never for a tap on another control. Anything focusable or clickable is
* about to take focus itself, and the accessory bar in particular exists to
* be used WHILE the keyboard is open, so dismissing there would fight the
* user. `closest()` covers taps landing on a child (an icon inside a button).
*
* Bound to `touchend` rather than `click`: a tap that dismisses the keyboard
* usually is not meant to activate whatever is underneath, and touchend fires
* before the synthesized click, so the blur lands first.
*/
_installMobileKeyboardDismiss() {
if (this._mobileKeyboardDismissHandler) return;
// A SCROLL also ends in touchend, and dismissing there is wrong: scrolling
// to read something while composing must not close the keyboard and lose
// the composer. Track how far the finger travelled and only treat a
// near-stationary gesture as a tap — the same TAP_THRESHOLD the terminal's
// own touch handling uses, so both agree on what a tap is.
let startX = 0;
let startY = 0;
let moved = false;
this._mobileKeyboardDismissStart = (ev) => {
if (ev.touches.length !== 1) {
moved = true; // a multi-touch gesture is never a dismissing tap
return;
}
startX = ev.touches[0].clientX;
startY = ev.touches[0].clientY;
moved = false;
};
this._mobileKeyboardDismissMove = (ev) => {
if (moved || !ev.touches.length) return;
const dx = ev.touches[0].clientX - startX;
const dy = ev.touches[0].clientY - startY;
const slop = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP;
if (Math.abs(dx) > slop || Math.abs(dy) > slop) {
moved = true;
}
};
this._mobileKeyboardDismissHandler = (ev) => {
if (moved) return;
if (!this._isMobileTerminalInputFocused()) return;
const target = ev.target;
if (!target || typeof target.closest !== 'function') return;
if (target.closest('#terminalContainer')) return;
if (target.closest(window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR)) return;
this._blurMobileTerminalInput();
};
// Passive throughout: this never calls preventDefault, so it must not make
// the page feel less responsive to scrolling.
document.addEventListener('touchstart', this._mobileKeyboardDismissStart, { passive: true });
document.addEventListener('touchmove', this._mobileKeyboardDismissMove, { passive: true });
document.addEventListener('touchend', this._mobileKeyboardDismissHandler, { passive: true });
},
/**
* Which 'content' taps should DISMISS the mobile keyboard. Expandable
* readbacks, tool results and decision rows are TUI-owned: tapping them acts
+87
View File
@@ -624,6 +624,93 @@ describe('Virtual Keyboard', () => {
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
});
it('dismisses the on-screen keyboard when a tap lands outside the terminal', async () => {
// The terminal holds focus on a hidden textarea and nothing released it,
// so once the keyboard was up every tap on the header or page chrome left
// it up — covering half a phone screen with no in-app way to close it.
//
// Driven as a real dispatched gesture: the handler is bound to touchend on
// document, and calling the internal helper would bypass the routing this
// test exists to check.
const result = await page.evaluate(async () => {
const tap = async (el: Element, travel = 0) => {
const rect = el.getBoundingClientRect();
const x = Math.max(2, rect.left + Math.min(6, rect.width / 2));
const y = Math.max(2, rect.top + Math.min(6, rect.height / 2));
const target = document.elementFromPoint(x, y) || el;
const at = (cy: number) => new Touch({ identifier: 21, target, clientX: x, clientY: cy });
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [at(y)],
targetTouches: [at(y)],
changedTouches: [at(y)],
bubbles: true,
cancelable: true,
})
);
for (const step of travel ? [travel / 3, (travel * 2) / 3, travel] : []) {
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [at(y + step)],
targetTouches: [at(y + step)],
changedTouches: [at(y + step)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 15));
}
await new Promise((resolve) => setTimeout(resolve, 25));
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
changedTouches: [at(y + travel)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 250));
return document.activeElement?.className ?? '';
};
app.hideWelcome();
app.terminal.reset();
await new Promise<void>((resolve) => app.terminal.write('transcript\r\n\r\n> ', resolve));
// Inert page chrome: the keyboard must close.
app._focusMobileTerminalInput();
const focusedBefore = document.activeElement?.className ?? '';
const afterOutside = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body);
// A real control: it takes focus itself, so we must NOT interfere.
app._focusMobileTerminalInput();
const button = Array.from(document.querySelectorAll('button:not([disabled])')).find((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.width > 8 && rect.height > 8;
});
const afterButton = button ? await tap(button) : 'no-visible-button';
// Inside the terminal, tap classification owns the decision.
app._focusMobileTerminalInput();
const afterTerminal = await tap(document.querySelector('#terminalContainer')!);
// A SCROLL also ends in touchend. Scrolling to read something while
// composing must not close the keyboard and drop the composer.
app._focusMobileTerminalInput();
const afterScroll = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body, 120);
return { focusedBefore, afterOutside, afterButton, afterTerminal, afterScroll };
});
expect(result.focusedBefore).toContain('xterm-helper-textarea');
// Red on master: the textarea keeps focus and the keyboard stays up.
expect(result.afterOutside).not.toContain('xterm-helper-textarea');
expect(result.afterButton).toContain('xterm-helper-textarea');
expect(result.afterTerminal).toContain('xterm-helper-textarea');
// A scroll ends in touchend too, and must NOT close the keyboard.
expect(result.afterScroll).toContain('xterm-helper-textarea');
});
it('routes CJK textarea typing through local echo on Enter', async () => {
await page.evaluate(() => {
window.__sentInputs = [];