Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions lib/input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@
private mousemoveListener: ((e: MouseEvent) => void) | null = null;
private wheelListener: ((e: WheelEvent) => void) | null = null;
private isComposing = false;
private compositionJustEnded = false; // Block keydown briefly after composition ends
private pendingKeyAfterComposition: string | null = null; // Key to output after composition
private isDisposed = false;
private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting
private lastKeyDownData: string | null = null;
Expand Down Expand Up @@ -288,14 +290,19 @@
this.inputElement.addEventListener('beforeinput', this.beforeInputListener);
}

// Attach composition events to inputElement (textarea) if available.
// IME composition events fire on the focused element, and when using a hidden
// textarea for input (as ghostty-web does), the textarea receives focus,
// not the container. This fixes Korean/Chinese/Japanese IME input.
const compositionTarget = this.inputElement || this.container;
this.compositionStartListener = this.handleCompositionStart.bind(this);
this.container.addEventListener('compositionstart', this.compositionStartListener);
compositionTarget.addEventListener('compositionstart', this.compositionStartListener);

this.compositionUpdateListener = this.handleCompositionUpdate.bind(this);
this.container.addEventListener('compositionupdate', this.compositionUpdateListener);
compositionTarget.addEventListener('compositionupdate', this.compositionUpdateListener);

this.compositionEndListener = this.handleCompositionEnd.bind(this);
this.container.addEventListener('compositionend', this.compositionEndListener);
compositionTarget.addEventListener('compositionend', this.compositionEndListener);

// Mouse event listeners (for terminal mouse tracking)
this.mousedownListener = this.handleMouseDown.bind(this);
Expand Down Expand Up @@ -365,7 +372,23 @@

// Ignore keydown events during composition
// Note: Some browsers send keyCode 229 for all keys during composition
if (this.isComposing || event.isComposing || event.keyCode === 229) {
if (event.isComposing || event.keyCode === 229) {

Check warning on line 375 in lib/input-handler.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'keyCode' is deprecated.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5VGIbFUST3WD3G_7lr&open=AZ5VGIbFUST3WD3G_7lr&pullRequest=11
return;
}

// If we're still in composition (our flag) but browser says composition ended,
// this is the key that ended the composition (space, period, etc.).
// Queue it to be processed after compositionend to maintain correct order.
if (this.isComposing) {
// Store the key to be processed after composition ends
this.pendingKeyAfterComposition = event.key;
event.preventDefault();
return;
}

// Block the key that triggered composition end if we just processed a pending key
if (this.compositionJustEnded) {
this.compositionJustEnded = false;
return;
}

Expand Down Expand Up @@ -689,13 +712,31 @@
if (data && data.length > 0) {
if (this.shouldIgnoreCompositionEnd(data)) {
this.cleanupCompositionTextNodes();
// Still process pending key even if composition data is ignored
this.processPendingKeyAfterComposition();
return;
}
this.onDataCallback(data);
this.recordCompositionData(data);
}

this.cleanupCompositionTextNodes();

// Process the key that ended composition (space, period, etc.)
// This ensures correct order: composed text first, then the terminating key
this.processPendingKeyAfterComposition();
}

/**
* Process the pending key that was queued during composition
*/
private processPendingKeyAfterComposition(): void {
if (this.pendingKeyAfterComposition) {
const key = this.pendingKeyAfterComposition;
this.pendingKeyAfterComposition = null;
// Output the key that ended composition
this.onDataCallback(key);
}
}
Comment on lines +733 to 740

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are three issues in this method:

  1. The compositionJustEnded flag is never set to true, which makes the logic in handleKeyDown (line 390) that blocks duplicate events ineffective.
  2. Storing and sending event.key directly is incorrect for special keys like Enter, Tab, or Backspace. In a terminal context, these must be mapped to their respective escape sequences (e.g., \r, \t, \x7F).
  3. This method should call recordKeyDownData to ensure proper de-duplication against subsequent beforeinput events, maintaining consistency with the rest of the input handling logic.
  private processPendingKeyAfterComposition(): void {
    if (this.pendingKeyAfterComposition) {
      const key = this.pendingKeyAfterComposition;
      this.pendingKeyAfterComposition = null;
      this.compositionJustEnded = true;

      // Map common terminating keys to their terminal sequences.
      // For printable characters (like Space or Period), the key is the data.
      let data = key;
      if (key === 'Enter') data = '\r';
      else if (key === 'Tab') data = '\t';
      else if (key === 'Backspace') data = '\x7F';
      else if (key === 'Escape') data = '\x1B';

      // Output the key that ended composition
      this.onDataCallback(data);
      this.recordKeyDownData(data);
    }
  }


/**
Expand Down Expand Up @@ -1079,18 +1120,20 @@
this.beforeInputListener = null;
}

// Remove composition listeners from the same element they were attached to
const compositionTarget = this.inputElement || this.container;
if (this.compositionStartListener) {
this.container.removeEventListener('compositionstart', this.compositionStartListener);
compositionTarget.removeEventListener('compositionstart', this.compositionStartListener);
this.compositionStartListener = null;
}

if (this.compositionUpdateListener) {
this.container.removeEventListener('compositionupdate', this.compositionUpdateListener);
compositionTarget.removeEventListener('compositionupdate', this.compositionUpdateListener);
this.compositionUpdateListener = null;
}

if (this.compositionEndListener) {
this.container.removeEventListener('compositionend', this.compositionEndListener);
compositionTarget.removeEventListener('compositionend', this.compositionEndListener);
this.compositionEndListener = null;
}

Expand Down
72 changes: 47 additions & 25 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,20 +351,20 @@ export class Terminal implements ITerminalCore {
this.isOpen = true;

try {
// Make parent focusable if it isn't already
if (!parent.hasAttribute('tabindex')) {
parent.setAttribute('tabindex', '0');
}

// Mark as contenteditable so browser extensions (Vimium, etc.) recognize
// this as an input element and don't intercept keyboard events.
parent.setAttribute('contenteditable', 'true');
// Prevent actual content editing - we handle input ourselves
parent.addEventListener('beforeinput', (e) => {
if (e.target === parent) {
e.preventDefault();
}
});
// Set tabindex="-1" on parent so it is not focusable via click/tab.
// We route ALL focus to the hidden textarea so IME composition events
// (Korean, Chinese, Japanese) fire on the element our listeners are
// attached to. Composition events fire on the focused element only.
//
// We intentionally do NOT set contenteditable on the parent container.
// Setting it caused IME (CJK) input to be inserted directly into the
// container as text nodes, bypassing our textarea.
//
// NOTE: removing contenteditable may bring back the browser-extension
// key-interception regression that #78 fixed with that attribute.
// The textarea is itself a real input element so most extensions
// (Vimium, etc.) should leave it alone — to be verified in browser.
parent.setAttribute('tabindex', '-1');

// Add accessibility attributes for screen readers and extensions
parent.setAttribute('role', 'textbox');
Expand Down Expand Up @@ -417,6 +417,20 @@ export class Terminal implements ITerminalCore {
ev.preventDefault();
textarea.focus();
});
// Redirect focus from the parent container to the textarea so that
// IME composition events always fire on the textarea (where our
// listeners live). Without this, clicking on the container border
// (outside the canvas) would put focus on parent — the textarea
// would not receive composition events.
parent.addEventListener('mousedown', (ev) => {
if (ev.target === parent) {
ev.preventDefault();
textarea.focus();
}
});
parent.addEventListener('focus', () => {
textarea.focus();
});

// Create renderer
this.renderer = new CanvasRenderer(this.canvas, {
Expand Down Expand Up @@ -763,15 +777,22 @@ export class Terminal implements ITerminalCore {
* Focus terminal input
*/
focus(): void {
if (this.isOpen && this.element) {
// Focus immediately for immediate keyboard/wheel event handling
this.element.focus();

// Also schedule a delayed focus as backup to ensure it sticks
// (some browsers may need this if DOM isn't fully settled)
setTimeout(() => {
this.element?.focus();
}, 0);
if (this.isOpen) {
// Focus the textarea (not the container) for keyboard / IME input.
// The textarea is the actual input element that receives keyboard
// events and IME composition events. Focusing the container does
// not work for IME because composition events fire on the focused
// element only.
const target = this.textarea || this.element;
if (target) {
target.focus();

// Also schedule a delayed focus as backup to ensure it sticks
// (some browsers may need this if DOM isn't fully settled)
setTimeout(() => {
target?.focus();
}, 0);
}
}
}

Expand Down Expand Up @@ -1268,8 +1289,9 @@ export class Terminal implements ITerminalCore {
this.element.removeEventListener('mouseleave', this.handleMouseLeave);
this.element.removeEventListener('click', this.handleClick);

// Remove contenteditable and accessibility attributes added in open()
this.element.removeAttribute('contenteditable');
// Remove accessibility attributes added in open().
// (contenteditable is no longer set on the parent — we focus the
// textarea directly for IME support; see open() comments.)
this.element.removeAttribute('role');
this.element.removeAttribute('aria-label');
this.element.removeAttribute('aria-multiline');
Expand Down