Full AI

Zig/AppKitで速読ツールを極小デスクトップアプリ化した [127KB] ... users

agent-rsvp-jaで青空文庫の坊っちゃんを1行速読表示しているGIF

はじめに

これは Full AI で書いた記事です。

agent-rsvp は、RSVP(Rapid Serial Visual Presentation)方式の速読ツールです。単語や短い塊を画面中央に次々出して、目を動かす距離を減らしながら読むためのものです。

もともとのツールはターミナル上で動く CLI/TUI として作られていて、英語の文章や短いテキストを読むには素直な作りでした。ただ、日本語の文章を読むために使ってみると、いくつか気になるところが出ました。

  • 日本語の文字幅と焦点文字の扱いが合わず、表示が右に突き出る
  • 赤い代表文字の位置が左右にぶれて、視線を固定できない
  • 1文字だけの表示や、不自然な区切りが混ざる
  • 行数を増やすと表示速度が落ちる
  • ターミナルを起動せず、普通の macOS アプリとして開きたい
  • 技術ブログでは英単語や記号が多く、純粋な速読サンプルとしては読みにくい

そこで、フォーク版として kazuph/agent-rsvp-ja を作りました。本家へ Pull Request は送っていません。日本語で気持ちよく読むための実験を、別フォークとして公開しています。

フォークの位置づけ

公開したリポジトリはこちらです。

リポジトリ名と package.json の名前は agent-rsvp-ja に変えています。ただし、この記事を書いている時点では agent-rsvp-ja は npm には公開していません。npm の agent-rsvp は本家名なので、npx agent-rsvp と書くと本家側を取りに行ってしまいます。

CLI の起動コマンドは、ローカルで build / link した後は従来どおり agent-rsvp のままです。普段使うコマンド名を変えずに、日本語向けの実装へ差し替えるためです。

agent-rsvp 坊っちゃん
agent-rsvp 吾輩は猫である
agent-rsvp セロ弾きのゴーシュ

ファイルを渡せば従来どおりそのファイルを読みます。存在しない名前を渡した場合は、青空文庫の作品名として解決します。

最初に困ったこと

RSVP では、表示される語や句のどこを見るかが大事です。英語向けの RSVP では、単語の左寄りに焦点文字を置く ORP(Optimal Recognition Point)の考え方がよく使われます。

でも日本語の文章でそれをそのまま使うと、赤い代表文字が左寄りに集まりがちでした。さらに表示全体も右方向に突き出ることが多く、視線を固定する速読の意味が薄くなります。

今回のフォークでは、表示する行ごとに次のルールにしました。

  • 表示行の空白を除いた中央文字を代表文字にする
  • 文字数が偶数なら中央右の文字を代表文字にする
  • 赤い代表文字の中心位置は、常に画面中央に固定する
  • 長い行はフォントサイズを落として、表示領域に収める

つまり、変な推測アルゴリズムではなく、「今表示している行の中央」を見るようにしました。日本語ではこのほうが読んでいて素直でした。

ターミナルをやめる

もうひとつ大きかったのが、ターミナル起動をやめたいという点です。

速読アプリは、読む画面そのものが主役です。ターミナルの中で動くより、普通の macOS アプリとして開き、キー操作だけで速度や行数を変えられるほうが使いやすい。

最初は TUI のまま改善していましたが、行数を増やしたときに表示速度が落ちる問題もありました。そこで、TUI の再描画から離れて、macOS 側は AppKit で直接描画する構成へ変えました。

この方向に踏み切るきっかけになったのが、Vercel Labs の zero-native です。

zero-native は Zig + WebView で小さなネイティブアプリを作るプロジェクトです。公式サイトでも、システム WebView を使うことで sub-megabyte の小さなアプリを作れることが説明されています。

Zero Native を調べていて良かったのは、「小さいデスクトップアプリ」を考えるときに、Electron のように巨大なブラウザランタイムを同梱する道だけではないと再確認できたことです。system WebView を使えば、Web UI でもかなり小さくできます。

一方で、今回必要だった UI は Web UI ではありませんでした。

今回必要だった画面は、テキストを1〜数行描くだけです。HTML/CSS のレイアウト、DOM、JavaScript bridge、ローカル origin、WebView のセキュリティモデルは、普通のアプリを作るなら便利ですが、この用途では持ち込む部品が多すぎます。欲しかったのは Web UI ではなく、固定位置に文字を描くための小さな描画面でした。

Zero Native を system WebView で使う場合、ブラウザランタイム自体はOS側の WebView を使うので、Electron のように Chromium 一式を抱えるわけではありません。手元で zero-nativeexamples/hello を、Zig のサイズ優先ビルド設定で system WebView 向けにビルドすると 442KB でした。同じ経路で RSVP 相当の inline HTML/JavaScript も作り、起動まで確認しましたが、サイズは同じく 442KB でした。

つまり、Zero Native/system WebView は十分に小さいです。ただし今回のように AppKit の drawRect: で文字を直接描けるだけのアプリでは、WebView runtime、bridge、HTML/CSS/JS 側の配布物、アプリ manifest などを足すより、Cocoa/AppKit に直接乗ったほうがさらに薄くできます。Zero Native は「Web UI を極小ネイティブアプリにする」ための選択肢で、今回は「Web UI すら不要だった」という判断です。

そこで最終的には、Objective-C/AppKit で表示部分を書き、Zig の build system でコンパイルとリンクをまとめる構成にしました。ここでの Zig は、UI を書く主役ではありません。Objective-C の .m、小さな Zig entry point、Cocoa / NaturalLanguage などの macOS フレームワーク、サイズ優先のビルド設定を、ひとつの build.zig に閉じ込めるための道具です。

実際、手元でビルドした agent-rsvp-native は、ビルド直後のバイナリが 109KB、インストール済みの署名後バイナリが 127KB でした。

$ ls -lh /tmp/agent-rsvp/zig-out/bin/agent-rsvp-native
-rwxr-xr-x  ... 109K agent-rsvp-native

$ ls -lh ~/.local/share/agent-rsvp/agent-rsvp-native
-rwxr-xr-x  ... 127K agent-rsvp-native

ここで大事なのは、Zig だけが小ささの理由ではないことです。小さい理由の中心は、AppKit / Foundation / NaturalLanguage などをバイナリに内包せず、macOS 標準フレームワークとしてリンクしている点です。Objective-C のコードも、Zig が魔法のように小さくしているわけではなく、最終的には clang/LLVM でコンパイルされます。

実際、Zig を介さない Objective-C/AppKit の最小 RSVP アプリを clang -Os で作ると 55KB、Swift/AppKit の最小 RSVP アプリを swiftc -Osize で作ると 68KB でした。どちらも実際にウィンドウ起動まで確認しています。

実装 条件 サイズ 起動確認
Objective-C/AppKit 最小 RSVP clang -Os 55KB
Swift/AppKit 最小 RSVP swiftc -Osize 68KB
現行 agent-rsvp-native Zig のサイズ優先ビルド 109KB / 127KB
Zero Native RSVP 相当 Zig 0.16.0 + system WebView + サイズ優先ビルド 442KB

Objective-C だけでも、Swift でも、小さい AppKit アプリは作れます。では Zig を使った意味は何かというと、Objective-C/AppKit という macOS ネイティブの最短経路を、余計な runtime や接続層を増やさずにビルド手順へ閉じ込められることです。

ここで効いているのは、Zig が C/Objective-C をプロジェクトの普通の材料として扱える点です。addCSourceFile で Objective-C のソースを足し、linkFramework で macOS フレームワークを明示的にリンクし、配布用の成果物としてインストールする。この流れを Zig の標準ビルドだけで書けます。

Rust でも Go でも、AppKit アプリを作ること自体は不可能ではありません。ただ、Rust なら build.rscc crate、objc2 などの Objective-C runtime binding、macOS framework link の指定が必要になります。Go なら cgo と Objective-C wrapper を挟み、さらに Go runtime も乗ります。今回のような「macOS 専用で、AppKit に薄く乗るだけの極小 GUI」では、その接続層が本体より重くなりやすい。

npm パッケージとして配布できるように Node 製 CLI ランチャーとネイティブ本体を分けている点もありますが、これは Zig 固有の利点ではありません。Rust でも Go でも同じように、ビルド済みの native binary を Node CLI から呼ぶ構成は作れます。npm 配布に備えた構成は本家の使い勝手を残すための選択で、Zig を選んだ理由の中心ではありません。

Zig のいちばん大きなメリットは、Objective-C/AppKit という macOS ネイティブの最短経路を、余計な runtime を増やさずに再現可能なビルド手順へ閉じ込められることでした。

実装の構成

構成はシンプルです。

index.ts              Node/Bun 側の薄い CLI ランチャー
native/main.zig       Zig の entry point
native/macos_app.m    AppKit のウィンドウ、描画、キー操作、本文整形
build.zig             Zig/AppKit バイナリのビルド

GitHub の Languages 表示を見ると、Zig は数%で、ほとんど Objective-C に見えます。これはその通りです。

このフォークは「アプリ全体を Zig で書いた」というものではありません。実態は、Zig の build system と小さな entry point で Objective-C/AppKit の実装を束ね、配布用にサイズを抑えたネイティブバイナリとして出している構成です。

exe.addCSourceFile(.{
    .file = b.path("native/macos_app.m"),
    .flags = &.{ "-fobjc-arc" },
});
exe.linkFramework("Cocoa");
exe.linkFramework("NaturalLanguage");

Zig 側の役割は、UIを大量に書くことではなく、次のあたりです。

  • 小さい単体バイナリとしてビルドする
  • Objective-C の .m を同じ build graph に入れる
  • Cocoa / NaturalLanguage など、必要な macOS フレームワークへ明示的にリンクする

Node 製の CLI ランチャーは、将来 npm パッケージとして配布しやすくするために残していますが、これは Zig でなくてもできます。Zig 側の役割とは分けて考えています。

Objective-C/AppKit 側に寄せたのは、AppKit がもともと Objective-C のAPIとして自然に扱えるからです。ウィンドウ、NSViewdrawRect:、キーイベント、NSOpenPanel は、薄いデスクトップアプリなら Objective-C で素直に書けます。

Swift で書き直しても成立しそうです。少なくともサイズだけを理由に Swift を避ける必要はありません。今回 Objective-C/AppKit に寄せたのは、AppKit の API をそのまま薄く扱えること、既存実装を小さく直しながら進めやすかったこと、Zig のビルドファイルに .m のコンパイルと macOS フレームワークのリンクをまとめられたことが理由です。ここでは「Zigのコード量が多いこと」ではなく、「AppKit 直描きで、WebViewもターミナルも挟まず、小さいまま高速に動くこと」を重視しています。

CLI は npm パッケージ化しやすいように Node 側に残しています。実際の表示は agent-rsvp-native が担当します。

agent-rsvp sample.md
agent-rsvp -w 450 sample.md
agent-rsvp -o sample.md
agent-rsvp -t sample.md
agent-rsvp --test-layout sample.md

-o を付けると、ネイティブウィンドウを detached で開きます。ターミナルを新しく起動することはありません。

日本語の区切り

日本語の区切りは、最初かなり迷いました。形態素解析を入れる案もありましたが、速読表示では「言語学的に正しい分割」よりも、「読んだときに変なところで止まらないこと」のほうが大事でした。

今回の実装では、句読点、助詞、助動詞、ASCII の技術語、短すぎる chunk の吸収を組み合わせています。

特に避けたかったのはこういう表示です。

ら、
が
「

1文字や2文字だけが単独で出ると、読むリズムが崩れます。そこで、短すぎる chunk は前後へ吸収する後処理を入れました。

確認では、青空文庫の 坊っちゃん で次の状態まで落としています。

$ agent-rsvp -t 坊っちゃん
total=12191 one_or_less=0 short_le2=0

冒頭の分割はこうです。

坊っ[ち]ゃん
夏目[漱]石
親譲りの[無]鉄砲で
小供の[時]から
損ばかり[し]ている。
小学校に居る時[分]学校の二階から
飛び降りて一[週]間ほど腰を
抜かし[た]事が
あ[る]。

角括弧の中が、固定位置に置く赤い代表文字です。

青空文庫を読む

技術ブログで試すと、英単語、パス、記号、コード片が混ざります。RSVP の表示そのものを調整するには、まず純粋な日本語本文で試したほうがわかりやすい。

そこで、青空文庫の作品名を直接指定できるようにしました。

agent-rsvp 坊っちゃん
agent-rsvp 吾輩は猫である
agent-rsvp セロ弾きのゴーシュ

内部では青空文庫の公式 UTF-8 索引を取得し、作品名からテキスト zip を探します。本文は Shift_JIS から UTF-8 に変換し、ルビ、入力注記、底本情報、単独の章番号行を落としてから表示します。

参照した青空文庫の情報です。

操作のこだわり

速読アプリとして、操作はかなり絞りました。

キー 動作
h / l 減速 / 加速
j / k 同時表示行数を減らす / 増やす
m / Tab minimal / context mode の切り替え
Cmd- / Cmd+ フォントサイズ変更
f フルスクリーン
space 一時停止 / 再開
r 最初から
q / Esc 終了

scrub という概念は消しました。読んでいる途中に「scrub」と言われても、何をするのか直感的ではなかったためです。

同時表示行数は 1 行から始めます。k で 2 行、3 行と増え、j で減ります。下限は 1 です。line:0 のような状態は表示しません。

minimal mode では、現在読んでいる chunk だけを表示します。

minimal modeで坊っちゃんを1行表示しているGIF

m / Tab で context mode に切り替えると、上下に前後の文脈が出ます。

minimal modeからcontext modeへ切り替えるGIF

k で同時表示行数を増やすと、複数行をまとめて流せます。

複数行表示で坊っちゃんを速読しているGIF

確認したこと

今回の記事を書く前に、少なくとも次を確認しました。

$ bun run build
$ agent-rsvp -t 坊っちゃん
total=12191 one_or_less=0 short_le2=0

GUI 起動も確認しました。

$ agent-rsvp 坊っちゃん

70秒ほど通常起動で流し、stderr なし、新しい crash report なしを確認しています。途中で AppKit の描画属性に nil が混ざってクラッシュする問題も見つけたため、フォント生成が失敗しても systemFont に落ちるように修正しました。

公開状態も確認しました。

$ gh repo view kazuph/agent-rsvp-ja --json url,visibility,defaultBranchRef

結果は、public repository、default branch は ja です。

おわりに

agent-rsvp を、日本語向けの agent-rsvp-ja としてフォークしました。

本家のシンプルな RSVP 体験を残しつつ、日本語の中央代表文字、固定焦点、青空文庫取得、Zig/AppKit の小さなネイティブウィンドウへ寄せています。

公開リポジトリはこちらです。

https://github.com/kazuph/agent-rsvp-ja

Enjoy, Japanese speed reading!