Skip to content

Commit 9217fb4

Browse files
committed
WIP mobile support
1 parent 1d03ec8 commit 9217fb4

File tree

3 files changed

+77
-28
lines changed

3 files changed

+77
-28
lines changed

demo/bin/demo.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,39 @@ const HTML_TEMPLATE = `<!doctype html>
286286
window.addEventListener('resize', () => {
287287
fitAddon.fit();
288288
});
289+
290+
// Handle mobile keyboard showing/hiding using visualViewport API
291+
if (window.visualViewport) {
292+
const terminalContent = document.querySelector('.terminal-content');
293+
const terminalWindow = document.querySelector('.terminal-window');
294+
const originalHeight = terminalContent.style.height;
295+
const body = document.body;
296+
297+
window.visualViewport.addEventListener('resize', () => {
298+
// When keyboard opens, visualViewport.height shrinks
299+
const keyboardHeight = window.innerHeight - window.visualViewport.height;
300+
if (keyboardHeight > 100) {
301+
// Keyboard is likely open
302+
// Remove body padding and center alignment to maximize space
303+
body.style.padding = '0';
304+
body.style.alignItems = 'flex-start';
305+
terminalWindow.style.borderRadius = '0';
306+
terminalWindow.style.maxWidth = '100%';
307+
// Shrink terminal to fit in visible viewport
308+
terminalContent.style.height = (window.visualViewport.height - 60) + 'px';
309+
// Scroll to top to ensure terminal is visible
310+
window.scrollTo(0, 0);
311+
} else {
312+
// Keyboard closed - restore styles
313+
body.style.padding = '40px 20px';
314+
body.style.alignItems = 'center';
315+
terminalWindow.style.borderRadius = '12px';
316+
terminalWindow.style.maxWidth = '1000px';
317+
terminalContent.style.height = originalHeight || '600px';
318+
}
319+
fitAddon.fit();
320+
});
321+
}
289322
</script>
290323
</body>
291324
</html>`;

lib/selection-manager.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -398,21 +398,18 @@ export class SelectionManager {
398398
// ==========================================================================
399399

400400
/**
401-
* Attach mouse event listeners to canvas
401+
* Attach mouse event listeners to textarea (which overlays the canvas)
402+
* The textarea is on top to receive touch events for mobile keyboard support
402403
*/
403404
private attachEventListeners(): void {
404405
const canvas = this.renderer.getCanvas();
406+
const target = this.textarea; // Events go to textarea since it's on top
405407

406408
// Mouse down - start selection or clear existing
407-
canvas.addEventListener('mousedown', (e: MouseEvent) => {
409+
target.addEventListener('mousedown', (e: MouseEvent) => {
408410
if (e.button === 0) {
409411
// Left click only
410-
411-
// CRITICAL: Focus the terminal so it can receive keyboard input
412-
// The canvas doesn't have tabindex, but the parent container does
413-
if (canvas.parentElement) {
414-
canvas.parentElement.focus();
415-
}
412+
// Textarea is already focused by the click, which triggers mobile keyboard
416413

417414
const cell = this.pixelToCell(e.offsetX, e.offsetY);
418415

@@ -430,8 +427,8 @@ export class SelectionManager {
430427
}
431428
});
432429

433-
// Mouse move on canvas - update selection
434-
canvas.addEventListener('mousemove', (e: MouseEvent) => {
430+
// Mouse move on textarea - update selection
431+
target.addEventListener('mousemove', (e: MouseEvent) => {
435432
if (this.isSelecting) {
436433
// Mark current selection rows as dirty before updating
437434
this.markCurrentSelectionDirty();
@@ -442,15 +439,15 @@ export class SelectionManager {
442439
this.requestRender();
443440

444441
// Check if near edges for auto-scroll
445-
this.updateAutoScroll(e.offsetY, canvas.clientHeight);
442+
this.updateAutoScroll(e.offsetY, target.clientHeight);
446443
}
447444
});
448445

449-
// Mouse leave - check for auto-scroll when leaving canvas during drag
450-
canvas.addEventListener('mouseleave', (e: MouseEvent) => {
446+
// Mouse leave - check for auto-scroll when leaving during drag
447+
target.addEventListener('mouseleave', (e: MouseEvent) => {
451448
if (this.isSelecting) {
452449
// Determine scroll direction based on where mouse left
453-
const rect = canvas.getBoundingClientRect();
450+
const rect = target.getBoundingClientRect();
454451
if (e.clientY < rect.top) {
455452
this.startAutoScroll(-1); // Scroll up
456453
} else if (e.clientY > rect.bottom) {
@@ -459,8 +456,8 @@ export class SelectionManager {
459456
}
460457
});
461458

462-
// Mouse enter - stop auto-scroll when mouse returns to canvas
463-
canvas.addEventListener('mouseenter', () => {
459+
// Mouse enter - stop auto-scroll when mouse returns
460+
target.addEventListener('mouseenter', () => {
464461
if (this.isSelecting) {
465462
this.stopAutoScroll();
466463
}
@@ -533,7 +530,7 @@ export class SelectionManager {
533530
document.addEventListener('mouseup', this.boundMouseUpHandler);
534531

535532
// Double-click - select word
536-
canvas.addEventListener('dblclick', (e: MouseEvent) => {
533+
target.addEventListener('dblclick', (e: MouseEvent) => {
537534
const cell = this.pixelToCell(e.offsetX, e.offsetY);
538535
const word = this.getWordAtCell(cell.col, cell.row);
539536

@@ -583,17 +580,18 @@ export class SelectionManager {
583580
// Focus the textarea so the context menu appears on it
584581
this.textarea.focus();
585582

586-
// After a short delay, restore the textarea to its hidden state
583+
// After a short delay, restore the textarea to its normal overlay state
587584
// This allows the context menu to appear first
588585
setTimeout(() => {
589586
// Listen for when the context menu closes (user clicks away or selects an option)
590587
const resetTextarea = () => {
591-
this.textarea.style.pointerEvents = 'none';
592-
this.textarea.style.zIndex = '-10';
593-
this.textarea.style.width = '0';
594-
this.textarea.style.height = '0';
588+
// Restore textarea to full overlay state for mobile keyboard support
589+
this.textarea.style.position = 'absolute';
595590
this.textarea.style.left = '0';
596591
this.textarea.style.top = '0';
592+
this.textarea.style.width = '100%';
593+
this.textarea.style.height = '100%';
594+
this.textarea.style.zIndex = '1';
597595
this.textarea.value = '';
598596

599597
// Remove the one-time listeners
@@ -611,7 +609,7 @@ export class SelectionManager {
611609
// Don't prevent default - let browser show the context menu on the textarea
612610
};
613611

614-
canvas.addEventListener('contextmenu', this.boundContextMenuHandler);
612+
target.addEventListener('contextmenu', this.boundContextMenuHandler);
615613

616614
// Click outside canvas - clear selection
617615
// This allows users to deselect by clicking anywhere outside the terminal

lib/terminal.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,12 @@ export class Terminal implements ITerminalCore {
325325
parent.setAttribute('tabindex', '0');
326326
}
327327

328+
// Ensure parent has position for absolute-positioned textarea
329+
const parentStyle = window.getComputedStyle(parent);
330+
if (parentStyle.position === 'static') {
331+
parent.style.position = 'relative';
332+
}
333+
328334
// Create WASM terminal with current dimensions and theme config
329335
const wasmConfig = this.buildWasmConfig();
330336
this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, wasmConfig);
@@ -341,22 +347,34 @@ export class Terminal implements ITerminalCore {
341347
this.textarea.setAttribute('autocorrect', 'off');
342348
this.textarea.setAttribute('autocapitalize', 'off');
343349
this.textarea.setAttribute('spellcheck', 'false');
344-
this.textarea.setAttribute('tabindex', '-1'); // Don't interfere with tab navigation
350+
this.textarea.setAttribute('tabindex', '0'); // Allow focus for mobile keyboard
345351
this.textarea.setAttribute('aria-label', 'Terminal input');
352+
this.textarea.setAttribute('enterkeyhint', 'send');
353+
this.textarea.setAttribute('inputmode', 'text');
346354
this.textarea.style.position = 'absolute';
347355
this.textarea.style.left = '0';
348356
this.textarea.style.top = '0';
349-
this.textarea.style.width = '0';
350-
this.textarea.style.height = '0';
351-
this.textarea.style.zIndex = '-10';
357+
this.textarea.style.width = '100%';
358+
this.textarea.style.height = '100%';
359+
this.textarea.style.zIndex = '1'; // Above canvas to receive touch events
352360
this.textarea.style.opacity = '0';
353361
this.textarea.style.overflow = 'hidden';
354-
this.textarea.style.pointerEvents = 'none'; // Don't interfere with mouse events normally
362+
this.textarea.style.caretColor = 'transparent';
363+
this.textarea.style.color = 'transparent';
364+
this.textarea.style.background = 'transparent';
355365
this.textarea.style.resize = 'none';
356366
this.textarea.style.border = 'none';
357367
this.textarea.style.outline = 'none';
368+
this.textarea.style.padding = '0';
369+
this.textarea.style.margin = '0';
370+
this.textarea.style.fontSize = '16px'; // Prevent iOS zoom on focus
358371
parent.appendChild(this.textarea);
359372

373+
// iOS requires explicit focus from touch event to show keyboard
374+
this.textarea.addEventListener('touchstart', () => {
375+
this.textarea.focus();
376+
});
377+
360378
// Create renderer
361379
this.renderer = new CanvasRenderer(this.canvas, {
362380
fontSize: this.options.fontSize,

0 commit comments

Comments
 (0)