Read OSS

PTYからピクセルへ:xterm.jsの統合とコンポーネントアーキテクチャ

上級

前提知識

  • 第3回:状態管理とRedux
  • Reactコンポーネントのライフサイクルとref
  • xterm.jsの基礎知識

PTYからピクセルへ:xterm.jsの統合とコンポーネントアーキテクチャ

PTYからIPCブリッジを経てReduxへと至るデータの流れを追ってきました。いよいよ最後のステップ、実際のターミナルコンテンツを画面に描画する部分です。Hyperがウェブ技術に賭けた恩恵と代償がもっとも顕著に現れるのがここです。高性能なターミナルレンダラー(xterm.js)をReactコンポーネントでラップするということは、ネイティブターミナルでは発生しないライフサイクル管理の課題を抱えることを意味します。

この記事では、xterm.jsを管理するTermコンポーネント、WebGLからCanvasへのフォールバックロジック、ReactのアンマウントやリマウントサイクルにDOM要素が対処する仕組みを見ていきます。さらに、レスポンシブなサイズ調整の仕組みと2層構造のキーボードショートカットアーキテクチャも詳しく解説します。

Termコンポーネント:xterm.jsのラッパー

TermコンポーネントReact.PureComponentを継承しており、単一のxterm.js Terminalインスタンスをラップします。主な責務は次のとおりです:

  • Terminalの生成と設定
  • アドオンの読み込み(Fit、Search、WebLinks、Canvas/WebGL、Unicode11、Image、Ligatures)
  • キーボードイベントの処理
  • ターミナルのDOM要素のライフサイクル管理
  • propの変更への対応(フォントサイズ、カラーなど)
  • 検索機能の提供

componentDidMountでのアドオン読み込みシーケンスを見ると、階層化されたアーキテクチャが浮かび上がります:

アドオン 用途 常に読み込まれるか
FitAddon ターミナルをコンテナに自動フィット はい
SearchAddon デコレーション付きのターミナル内検索 はい
WebLinksAddon クリッカブルなURL はい
CanvasAddonまたはWebglAddon GPUアクセラレーションによるレンダリング どちらか一方
Unicode11Addon Unicode 11の完全な文字幅サポート はい
LigaturesAddon フォントリガチャのサポート Canvasのみ
ImageAddon インライン画像表示(iTerm2プロトコル) 設定で有効な場合

コンストラクタでは、自身をグローバルなtermsレジストリ(terms[this.props.uid] = this)へ登録します。これが第3回で紹介したwriteミドルウェアが、Reactを介さずxterm.jsへ直接書き込むための登録ポイントです。

レンダラーの選択とWebGLフォールバック

HyperのレンダリングパイプラインはWebGLとCanvasの選択に関して3段階のフォールバックロジックを持っています:

lib/components/term.tsx#L189-L205

flowchart TD
    A["webGLRenderer config = true?"] -->|No| F["Use Canvas"]
    A -->|Yes| B["Background needs transparency?"]
    B -->|Yes| F
    B -->|No| C["WebGL2 supported?"]
    C -->|No| F
    C -->|Yes| D["Use WebGL"]
    D --> E{"WebGL context lost?"}
    E -->|Yes| G["Dispose WebGL\nFallback to Canvas"]
    E -->|No| H["Continue with WebGL"]

WebGLが使用されるのは、設定で有効化されている、背景色が完全に不透明である、ブラウザがWebGL2をサポートしている——この3条件をすべて満たす場合のみです。透明度のチェックが必要なのは、xterm.jsのWebGLレンダラーが透明背景をサポートしていないためです。macOSでウィンドウの透明化をサポートするHyperにとって、これは重要な制約です。

lib/components/term.tsx#L227-L231に実装されたWebGLコンテキストロストのハンドラーが、実行時の耐障害性を担います:

webglAddon.onContextLoss(() => {
  console.warn('WebGL context lost. Falling back to canvas-based rendering.');
  webglAddon.dispose();
  this.term.loadAddon(new CanvasAddon());
});

WebGLコンテキストは、GPUに負荷がかかっているときやシステムがサスペンドするときに失われることがあります。Hyperはそういった状況でも空白のターミナルを表示するのではなく、Canvasレンダラーにシームレスに切り替えます。

ヒント: リガチャアドオンはCanvasレンダラー使用時のみ読み込まれます(props.disableLigatures !== true && !useWebGL)。Fira CodeなどのリガチャフォントをHyperで使用していてリガチャを有効にしたい場合は、設定でwebGLRendererfalseにしてください。

DOMの保持とレスポンシブなサイズ調整

ペインを分割する操作を行うと、Reactのコンポーネントツリーが再構成され、コンポーネントが頻繁にマウント・アンマウントされます。しかしxterm.jsは、カーソル位置やスクロールバックバッファ、選択状態といった内部状態をDOM要素に保持しています。Reactのリマウントのたびにターミナルを破棄・再生成するのは現実的ではありません。

Hyperはこの問題を、ターミナルのDOM要素をReactの管理外で扱うことで解決しています:

lib/components/term.tsx#L182-L187

// The parent element for the terminal is attached and removed manually so
// that we can preserve it across mounts and unmounts of the component
this.termRef = props.term ? props.term.element!.parentElement! : document.createElement('div');
this.termRef.className = 'term_fit term_term';
this.termWrapperRef?.appendChild(this.termRef);

Termコンポーネントがマウントされ、既存のprops.term(以前に生成されたTerminalインスタンス)を受け取った場合、新しいDOM要素を作るのではなく、そのターミナルの既存DOM要素を再利用します。アンマウント時(componentWillUnmount)は、DOM要素を切り離しますが、破棄はしません

componentWillUnmount() {
  terms[this.props.uid] = null;
  this.termWrapperRef?.removeChild(this.termRef!);
  // We remove listeners instead of invoking `destroy`, since it will make the
  // term insta un-attachable in the future
  this.disposableListeners.forEach((handler) => handler.dispose());
}

レスポンシブなサイズ調整には、500msのデバウンス付きResizeObserverを使用しています:

lib/components/term.tsx#L483-L494

sequenceDiagram
    participant RO as ResizeObserver
    participant T as Term Component
    participant FA as FitAddon
    participant PTY as PTY (via RPC)

    Note over RO: Container div resizes
    RO->>T: Callback fires
    T->>T: Clear existing timeout
    T->>T: Set 500ms timeout
    Note over T: 500ms debounce
    T->>FA: fitAddon.fit()
    FA->>FA: Calculate cols/rows from container dimensions
    FA->>T: Terminal resized (triggers onResize)
    T->>PTY: rpc.emit('resize', {uid, cols, rows})
    PTY->>PTY: pty.resize(cols, rows)

500msのデバウンスは、ウィンドウのドラッグ操作中に発生するリサイズの連発を抑えるためのものです。FitAddonはコンテナのピクセルサイズと現在のフォントメトリクスから最適な列数・行数を算出し、ターミナルをリサイズします。そのリサイズがonResizeコールバックをトリガーし、新しいサイズをRPC経由でPTYに伝えます。

コンポーネントツリーとスプリットペインのレンダリング

コンポーネントの階層構造は、第3回で解説したterm groupsリデューサーのコンセプトに直接対応しています:

graph TD
    HC["HyperContainer<br/>(Redux connected)"]
    H["Header<br/>(tabs, window controls)"]
    TC["TermsContainer<br/>(Redux connected)"]
    TG1["TermGroup<br/>(root, direction: VERTICAL)"]
    SP["SplitPane<br/>(flexbox container)"]
    TG2["TermGroup<br/>(left leaf)"]
    TG3["TermGroup<br/>(right leaf)"]
    T1["Term<br/>(xterm.js instance)"]
    T2["Term<br/>(xterm.js instance)"]

    HC --> H
    HC --> TC
    TC --> TG1
    TG1 --> SP
    SP --> TG2
    SP --> TG3
    TG2 --> T1
    TG3 --> T2

TermGroupコンポーネントは再帰的な構造を持ちます。sessionUidを持つリーフノードであればTermをレンダリングし、そうでなければ子のTermGroupを含むSplitPaneをレンダリングします:

lib/components/term-group.tsx#L122-L139

非アクティブなタブグループはアンマウントせず、left: -9999emで画面外に配置します。TermsコンポーネントのCSSを見るとその実装がわかります:

.terms_termGroup {
  position: absolute;
  top: 0;
  left: -9999em; /* Offscreen to pause xterm rendering */
}
.terms_termGroupActive {
  left: 0;
}

これはxterm.jsのIntersectionObserverベースのレンダリング最適化を活用したアプローチです。ターミナル要素が画面外にあるとき、xterm.jsはレンダリングを一時停止します。コンポーネントをアンマウント・リマウントするコストを払わずに、非アクティブなタブのCPU消費を抑えられます。

キーボードショートカットの仕組み

Hyperのキーボード処理は、互いに干渉しないよう慎重に調整された2つのシステムで成り立っています:

  1. Mousetrap — グローバルなキーボードショートカット(Cmd+Tで新しいタブ、Cmd+Dで分割など)をキャプチャするライブラリ
  2. xterm.js — シェルへの入力を処理するターミナル独自のキーハンドラー

この2つを調整するのがevent.catchedフラグです。Mousetrapがセットし、xterm.jsが読み取るカスタムプロパティです。

lib/containers/hyper.tsx#L57-L69では、Mousetrapがキーボードショートカットをバインドし、マッチしたイベントにマークを付けます:

mousetrap.current?.bind(commandKeys, (e) => {
  const command = keys[commandKeys];
  (e as any).catched = true;  // Flag for xterm
  props.execCommand(command, getCommandHandler(command), e);
  shouldPreventDefault(command) && e.preventDefault();
}, 'keydown');

そしてlib/components/term.tsx#L419-L422では、xterm.jsのカスタムキーイベントハンドラーがこのフラグを確認します:

keyboardHandler(e: any) {
  return !e.catched;
}

e.catchedtrueであれば、xterm.jsはそのイベントを無視します(ハンドラーがfalseを返す)。falseであれば、ターミナルの入力として通常どおり処理します。

sequenceDiagram
    participant K as Keyboard Event
    participant M as Mousetrap
    participant X as xterm.js
    participant C as Command Registry
    participant RPC as RPC → Main

    K->>M: keydown event (e.g., Cmd+T)
    M->>M: Match against registered shortcuts
    alt Shortcut matched
        M->>K: Set e.catched = true
        M->>C: Look up command handler
        C->>RPC: Execute command (e.g., 'tab:new')
        K->>X: attachCustomKeyEventHandler
        X->>X: Check e.catched → true → ignore
    else No match
        K->>X: attachCustomKeyEventHandler
        X->>X: Check e.catched → false → process as input
    end

lib/command-registry.tsのコマンドレジストリには、window:closeeditor:copyといった"ロールコマンド"のリストが別途管理されています。これらはpreventDefaultを呼び出すべきでなく、Electronのネイティブメニューシステムが処理するため、デフォルトのブラウザ動作を維持する必要があります。

app/commands.ts#L9-L139のメインプロセスのコマンド定義を見ると、コマンドの全体像がわかります。ウィンドウ管理、タブナビゲーション、ペイン分割、フォントズーム、エディタショートカット(単語の移動、行の削除)、plugin管理など多岐にわたります。プロファイル固有のコマンドはapp/commands.ts#L150-L163で動的に生成されます——各プロファイルに対して独自のwindow:new:profileNametab:new:profileName、スプリットコマンドが用意されます。

ヒント: Hyperのキーボードに関する問題をデバッグするときは、2点を確認しましょう。①Mousetrapがそのキーをキャッチしているかどうか(DevToolsでwindow.mousetrapを確認します)。②xterm.jsがそのキーを受け取っているかどうか(keyboardHandlerメソッドにconsole.log(e.catched)を追加して確認します。catchedフラグのプロトコルは壊れやすく、Mousetrapとxterm.jsの間にpluginがイベントリスナーを追加すると、この調整の仕組みが機能しなくなることがあります。

次回に向けて

全4回にわたって、PTYからピクセルまでのデータの完全な流れを追ってきました。この連載を通じて繰り返し登場したのが、あらゆる部分に関わるひとつのシステム、pluginです。コンポーネントをラップするdecorate()関数、Reduxアクションをインターセプトするmiddleware、共有依存関係のためのModule._loadのパッチング。これらはすべてHyperの強力なpluginアーキテクチャの一部です。次回は、38のすべての拡張ポイントと、それらが連携してターミナルのあらゆる部分をカスタマイズ可能にする仕組みを詳しく見ていきます。