DartでGUIアプリケーションを書いてみたい。ただし、今回はFlutterを使わずに。
そんな興味から、macOSのGUI APIをDartから扱うためのdart_appkitと、その上で動くターミナルエミュレーターdart_terminalを開発しています。
題材にしたいのは、Ghosttyのようなターミナルです。シェルの出力を表示するだけでなく、日本語を入力でき、タブやペインを使い分けられ、macOSのアプリとして自然に操作できるものを目指しています。
なお、コーディングはほとんどGPT-5.6 Sol君にやってもらってます。私はやりたいことをひたすら日本語で列挙しただけです。
手元の開発マシンはM1 Macなので、まずはmacOSに絞っています。現在の対象はmacOS 14以降で、実機での開発・検証の基準はApple M1/arm64です。LinuxやWindowsへの対応は、今の開発範囲には含めていません。
この記事では、Flutterを使わずにDartとAppKitをどうつないでいるかと、その土台でターミナルがどこまで動くようになったかを紹介します。内容は2026年9月12日時点の開発状況です。
筆者について
普段は、web application engineerとして、とある企業のwebサービスだったり、企業の中で使われる業務ツールを開発したりしています。フロントエンドは主にReactを使い、バックエンドはnodejsとexpress.jsだったりFastifyを使って実装しています。
Dartは、知ってはいましたが、現時点でほとんど書いたことがありません。
じゃあなんでDart選んだのってところですが、なんででしょうね笑。言語の仕組みが気になったから、でしょうか。あまり深い理由はなく、なんか面白そうだから、が正直なところです。
Dartから、macOSのGUI APIを使う
今回試したいのは、OSが持つGUIの部品をDartから操作し、アプリケーションの振る舞いをDartで書くという作り方です。
Flutterは、基本的には自身のWidget・レイアウト・描画の仕組みでUIを構築します。OSとの接続を担当するネイティブのembedderも、その構成の一部です。この役割分担はFlutterのアーキテクチャ解説に整理されています。
dart_appkitでは、ウィンドウやメニューなどにmacOSのAppKitを使います。Dart側にWindowやMenuといったAPIを用意し、その呼び出しをネイティブ側のAppKit操作につなぎます。
目標は、AppKit全体を一度にDartへ移すことではありません。ウィンドウ、ビュー、メニュー、入力イベントなど、アプリを作るために必要な機能から、再利用できるライブラリとして整えています。
SDKのコード、つなぎのコード、アプリのコード
ここで、Dartを実行する仕組みと、GUIを操作する仕組みを分けておきます。全体像は次のようになります。
図1:実線は呼び出し、点線は実行関係または通知の流れ。SDK、接続コード、アプリの担当を分けています。
それぞれの担当を、もう少し具体的にするとこうなります。
| 部分 | どこから来るか | 担当すること |
|---|---|---|
| Dart VM/Dart Engine | 公式Dart SDKのソース | Dartの実行環境を初期化し、コンパイルしたコードを読み込み、isolateやメッセージ処理を扱う |
| ネイティブホスト | 自作のdart_macos_runtime
|
AppKitアプリを起動し、その中でDartを動かす。起動・終了やアプリの梱包も担当する |
| AppKitブリッジとDart API | 自作のdart_appkit
|
Dartの操作をAppKitへ伝え、AppKitのイベントをDartへ返す |
| アプリケーション | 自作のdart_terminal
|
ウィンドウの構成、設定、セッション管理、端末の解析・状態管理などをDartで実装する |
| AppKit | macOS | 実際のネイティブウィンドウやメニュー、イベント配送を提供する |
ここで使っている「Dart Engine」は、Dart SDKのruntime/engineにある埋め込み用の仕組みです。Flutter Engineとは別で、AppKitのウィンドウを描いたり、ターミナルの制御シーケンスを解釈したりするものではありません。ネイティブプログラムの中でDartを実行するために使います。
現在はDart 3.13.2の公式リビジョンに固定し、SDKのソースを改変せずに必要なEngineライブラリをビルドしています。通常配布のDart SDKには、このホストが必要とする共有Engineライブラリが含まれていないためです。コンパイラと実行側の組み合わせもそろえています。
一方、Dartからネイティブ関数を呼ぶ部分には、SDKのdart:ffiを使います。その先に小さなC ABI、つまりCの関数として呼べる境界を設け、Objective-C/Objective-C++のコードでAppKitを操作します。
たとえばDart側でウィンドウのタイトルを変更すると、ブリッジが対応するAppKitオブジェクトを操作します。閉じるボタンやリサイズなどのイベントは、Dartのportに通知し、Dart側のイベント処理へ戻します。
ネイティブオブジェクトの寿命やスレッドの制約も、この接続部分の仕事です。アプリ側へ生のObjective-Cポインタを渡す代わりに、ライブラリが管理するオブジェクトを公開し、不要になったらdispose()で解放します。
AppKitのイベントループの中でDartを動かす
GUIとの接続では、「誰がメインスレッドとイベントループを管理するか」も決める必要があります。AppKitのイベントループはNSApplication.run()によって動きます。
この構成では、ネイティブホストがNSApplicationを起動し、AppKitのメインスレッド上でDartのUI用root isolateを実行します。isolateは、独立した状態を持つDartの実行単位です。
AppKitのrun loopを外側に置き、Dart側に処理すべきメッセージが来たら、ホストがメインスレッドで少しずつ処理します。1回に処理する件数と時間には上限を設けています。ただし、実行中のDartハンドラを途中で止める仕組みではないので、UI側の処理自体を短く保つことも必要です。
また、AppKitのイベントコールバックからDartへ同期的に入り直すことは避けています。イベントはいったん通知として渡し、Dart側で後から処理します。
現在採用している埋め込み構成は、GUIプロセスの生存期間を通して1つのUI rootを持つものです。このrootから通常のIsolate.spawnで自由にworkerを増やす構成は保証していません。画像デコードなど、別の実行単位へ任せる処理には、公式Dartで動く子プロセスworkerを使っています。
アプリを書く側からは、DartのAPIとして見える
こうした土台を用意すると、アプリ側では次のようにGUIを組み立てられます。汎用ホストから起動されることを前提に、ウィンドウを表示して閉じるまでを抜き出した例です。
import 'package:dart_appkit/dart_appkit.dart';
Future<void> main(List<String> arguments) async {
final app = await AppKitApplication.attach();
final view = TextView()..text = 'Hello from Dart!';
final window = Window(
frame: const Rect.fromLTWH(120, 120, 640, 360),
title: 'Dart AppKit',
)..contentView = view;
final closed = window.onClosed.first;
window.show();
await closed;
window.dispose();
view.dispose();
await app.terminate();
}
このコードは、すでに起動しているAppKitホストに接続します。実行にはホストとEngineの準備が必要で、単独のdart runだけでGUIを起動する例ではありません。
ビルドや.appへの梱包は、同じリポジトリ内の別パッケージdart_macos_runtimeが担当します。アプリはDartのエントリーポイントとJSONの宣言ファイルを渡し、共通のホストを使います。現在は開発用のJITと、事前コンパイルするRelease AOTの両方で動作を検証しています。
アプリを増やすたびに専用のネイティブ起動コードを書く必要がない、という形にしたいと考えています。
その上で、ターミナルをDartで実装する
dart_terminalは、この土台を使うGUIアプリケーションです。
Ghosttyは目指す機能や品質の比較対象として参照しています。端末の実装にはGhosttyやlibghosttyを組み込まず、制御シーケンスの解析や画面モデルをDartで独立実装しています。
ターミナルでは、シェルから受け取った文字列をテキストビューへ追加するだけでは足りません。出力には「カーソルを移動する」「色を変える」「画面の一部を消す」といった命令も混ざっています。それを解析し、画面のどのセルに何を表示するかを更新する必要があります。
図2:シェルの出力が画面になるまで。入力はAppKitからDartの入力処理へ入り、端末モードに応じたバイト列にしてPTYへ送ります。
PTYは疑似端末です。シェルから見ると端末につながっている状態を作り、入出力だけでなく、画面サイズやシグナル、ジョブ制御も扱います。このOS側の処理はdart_pty_macosに分離しています。
文字の整形やフォントのfallbackにはCoreTextを使い、描画にはMetalを使います。端末用のネイティブビューやGPU資源はdart_terminal_renderer_macosの担当です。Dart側で描画モデルを作り、変更箇所などをまとめて渡す構成にして、セルごとにFFIを呼ばないようにしています。
dart_appkitは、その端末ビューを通常のAppKitビューとしてウィンドウに取り付けます。PTYや端末専用レンダラーを別パッケージにしたことで、一般的なGUIアプリがターミナル固有の機能まで抱え込まずに済みます。
ここでいう 「Dartだけでアプリを書く」は、アプリケーション層をDartで書くという意味 です。OSとの接続、PTY、文字描画、Metalシェーダーなどにはネイティブコードがあります。それを再利用できる依存ライブラリ側に置き、アプリ本体の端末ロジックやUIの状態管理はDartに集めています。
現在できるようになったこと
シェルを継続して使う
現在は、1つのペインに1つのPTYと、長く動き続ける対話的なログインシェルを持つ構成です。既定のzshでコマンドを実行でき、同じシェルの中でcdした場所や環境変数を保持できます。
シェル自身の行編集やコマンド履歴に加え、Ctrl-C、Ctrl-Z、jobs、fg/bgなども扱います。ウィンドウやペインのサイズを変えるとPTYのサイズも更新し、子プロセスへリサイズを通知します。
シェルが正常終了した場合はペインを閉じ、異常終了の場合は理由を表示して残します。実行中のセッションを閉じるときの確認や、アプリ終了時の後始末も実装しています。
端末の画面をDartで管理する
UTF-8のストリームとVT制御シーケンスを解析し、カーソル移動、消去、スクロール、文字属性などを画面モデルへ反映します。通常画面と代替画面を持ち、ANSIの16色・256色・truecolorにも対応しています。
スクロールバックや選択・コピーに加え、リサイズ時の折り返し直しも実装しています。Unicode 17の書記素境界と文字幅を使い、日本語の全角文字や結合文字、絵文字のまとまりを、端末のセルと対応付けて扱います。
表示はCoreTextとMetalに接続済みで、Retinaの倍率、フォントfallback、カーソル、選択範囲などを反映します。
日本語入力とマウス操作
日本語入力では、確定した文字列だけでなく、変換中の文字列、下線、選択、変換候補の表示位置も必要になります。
そのため、端末ビューにNSTextInputClientを実装し、IMEの未確定文字列を表示用の重ね描きとして扱っています。変換中の表示で端末本体の画面データを書き換えず、確定した入力をPTYへ送ります。
US/JIS配列、dead key、日本語・中国語・韓国語の入力などを扱う検証も用意しています。ただし、自動化した入力経路の確認と、実際の入力ソースや物理キーボードを使う手動確認は区別しています。
マウスでは文字・単語・行の選択、ドラッグによる自動スクロール、トラックパッドの精密スクロールに対応しています。TUIアプリがマウス入力を要求している場合には端末プロトコルとして送り、Shiftによるローカル選択の切り替えも扱います。
コピー&ペースト、bracketed paste、OSC 8ハイパーリンクも実装しています。貼り付け内容に応じた確認や、リンクを開くときのURL検証も、この入力経路に含めています。
ウィンドウ、タブ、ペインを使い分ける
複数ウィンドウ、AppKitのネイティブタブ、左右・上下へのペイン分割が使えるようになっています。
| 操作 | 既定のショートカット |
|---|---|
| 新しいウィンドウ | Command-N |
| 新しいタブ | Command-T |
| 右に分割 | Command-D |
| 下に分割 | Shift-Command-D |
| コマンドパレット | Shift-Command-P |
| 設定画面 | Command-, |
分割の境界はドラッグやキーボードで調整でき、ペインの均等化やズームも扱います。新しいタブやペインへ、確認できたローカルの作業ディレクトリを引き継ぐ機能もあります。
ウィンドウの配置、タブ、分割、作業ディレクトリなどの状態復元にも対応しています。復元時には新しいシェルを起動するため、終了前のプロセスや画面履歴をそのまま再開する機能とは区別しています。
設定、テーマ、Quick Terminal
設定はkey = value形式のファイルで記述でき、フォント、色、余白、スクロールバック、カーソル、シェル、キーバインドなどを指定できます。アプリ内にも設定を編集・検証・保存する画面があります。
設定の再読み込みでは、すぐに反映する項目と、新しいセッションから反映する項目を分けています。テーマはライト/ダークの固定指定と、macOSの外観への追従に対応しています。
また、画面上端から呼び出すQuick Terminalも実装しました。非表示にしてもシェルは保持し、再表示して操作を続けられます。メニューやコマンドパレットから呼び出せるほか、任意でシステム全体のショートカットを設定できます。グローバルショートカットは既定では無効です。
新しい端末プロトコルとmacOS連携
基本的な端末動作に加えて、次の機能も実装しています。
| 機能 | 現在の対応内容 |
|---|---|
| Kitty keyboard protocol | 対応アプリと拡張キー情報をやり取りし、押下・リピート・解放などを扱う |
| Synchronized output | アプリが指定した更新区間の途中表示を抑え、終了指定またはタイムアウトで画面を更新する |
| Kitty graphics | 対応範囲内のRGB/RGBA/PNG画像とアニメーションを表示する。ファイル・共有メモリ経由など、未対応の方式もある |
| OSC 52 | 端末出力からのクリップボード操作を、読み取り・書き込み別の許可方針で制御する。既定は両方拒否 |
| シェル連携 | 作業ディレクトリ、タイトル、プロンプト位置を受け取り、プロンプト間の移動などに使う |
| 通知・進捗 | 対応する端末プロトコルの通知や進捗を、件数制限などの方針に従ってmacOSへ反映する |
| Secure Keyboard Entry | フォーカス中の端末のECHO状態に応じた自動取得と手動切り替え、所有中の表示を行う |
| VoiceOver向けの情報 | 可視テキスト、選択、カーソル、フォーカスをネイティブのアクセシビリティ情報として公開する |
「表示できた」から、日常的に使える品質へ
機能が増えても、それだけでGhosttyと同じ品質になったとは言えません。特にターミナルでは、特定のアプリが一度起動したことと、そのアプリの操作や画面更新を正しく扱えることには距離があります。
そのため、VT解析のテスト、出力バイト列と画面状態の記録再生、入力・PTY・実際のAppKit/Metalを通る結合検証を用意しています。tmux、SSH、mosh、Neovim、Emacs、ncurses、fzf、lazygitについても、バージョンと操作シナリオを固定した互換性の記録を持っています。これは確認した範囲の証拠であり、全操作の完全互換を示すものではありません。
現在はmacOSとの連携をさらに整えている段階です。Quick Look、Services、ドラッグ&ドロップ、コンテキストメニューは、基盤の一部ができていても、ターミナルへの統合まで完了した機能としては数えていません。アクセシビリティ全体の確認、AppleScript/Shortcuts連携、配布用の署名・公証、継続的な性能検証なども残っています。
DartでGUIを書きたいというところから始まり、今はそのDartコードで端末の状態を管理し、AppKitのウィンドウの中でシェルを操作できるところまで来ました。
最後に
多分すぐわかると思いますが、この記事もほとんどLLMに書いてもらってます。ただただ便利だから。
いまだに何が正解なんだろうかと考えている部分は、appkitのメインループとDartのイベントループの共存の設計です。個人的には配布されているDart SDKをそのまま何も変えずにappkitと連携できると汎用性があっていいなと思っていたのですが、全体の設計とDartが公開しているAPIが原因で断念することになりました。結局、Dart SDKのfetchして、必要なコードを使ってDartの実行環境を含むdart_appkitをビルドする、という方針になりました。
だけど、なんかモヤモヤします。自分だけの感情なのかもしれません。
Dartの開発者的には、そんなもんFlutter使えや、ってことなんでしょう。
これからもう少しDartで遊ぶつもりです。また何か形になったらここで記事を投稿するかもしれません。