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で使用していてリガチャを有効にしたい場合は、設定でwebGLRendererをfalseにしてください。
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つのシステムで成り立っています:
- Mousetrap — グローバルなキーボードショートカット(Cmd+Tで新しいタブ、Cmd+Dで分割など)をキャプチャするライブラリ
- 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.catchedがtrueであれば、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:closeやeditor:copyといった"ロールコマンド"のリストが別途管理されています。これらはpreventDefaultを呼び出すべきでなく、Electronのネイティブメニューシステムが処理するため、デフォルトのブラウザ動作を維持する必要があります。
app/commands.ts#L9-L139のメインプロセスのコマンド定義を見ると、コマンドの全体像がわかります。ウィンドウ管理、タブナビゲーション、ペイン分割、フォントズーム、エディタショートカット(単語の移動、行の削除)、plugin管理など多岐にわたります。プロファイル固有のコマンドはapp/commands.ts#L150-L163で動的に生成されます——各プロファイルに対して独自のwindow:new:profileName、tab: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のすべての拡張ポイントと、それらが連携してターミナルのあらゆる部分をカスタマイズ可能にする仕組みを詳しく見ていきます。