画面はファイルだ — 四行から始めるUIランタイム

5 分
目標画面講座 第1回。クライアント講座4回目でサーバーから届いたあの塊が、正確には何なのか。四行のJSON一つと、その中を決して覗かないホスト一つから始める。

クライアント講座 第4回で画面が届いた。ui://desk を読むと546バイトが来て、クライアントはそれをただの文字列として受け取り、長さを数えて終わった。

このトラックはその文字列が絵になる側だ。

四行

{ "type": "page", "title": "Step 1",
  "content": { "type": "center",
    "child": { "type": "text", "text": "The whole screen is this file",
               "style": { "fontSize": 34, "fontWeight": "bold", "color": "#111827" } } } }

233バイト。これが画面のすべてだ。ノードが三つある。

  • page — ひとつの画面。タイトルを持つ。
  • center — ひとつの子を中央に置く。
  • text — 文字。

木だ。page の中に content がひとつ、center の中に child がひとつ。複数を抱えるノードは第2回で出てくる。

これを描く側

final rt = MCPUIRuntime(enableDebugMode: false);
// screen = jsonDecode(ファイルの中身)
await tester.runAsync(() => rt.initialize(screen));
await tester.pumpWidget(RepaintBoundary(
  key: key,
  child: MaterialApp(
    debugShowCheckedModeBanner: false,
    home: Builder(
      builder: (c) => rt.buildUI(
        context: c,
        onToolCall: (tool, params) async => fired.add(tool),
      ),
    ),
  ),
));

initialize にマップを渡し、buildUI がウィジェットを返す。その間に画面についての判断はない。

このホストがテストの中で動くのはキャプチャを撮るためだ。アプリなら pumpWidget の位置に runApp が来る — 前後の二行はそのままだ。

onToolCall は第4回で使う。今は名前を書き取るだけだ。

何が無いかが要点だ

このトラックのホストは host/test/capture_test.dart というファイル一つ。五回が終わるまでほとんど変わらない — 五つの画面すべてを同じホストが描く。

だからこの講座でいちばん大事な検査は「有るか」ではなく 「無いか」 だ。

# 画面に見える文字がプログラムの中にあれば失敗する。
grep -rq 'The whole screen is this file' $SRC && \
  die "step1: the words on the screen are inside the host"

# ウィジェットを手で組んでいれば失敗する。
grep -rqE '\bText\(|\bColumn\(|\bRow\(|\bElevatedButton\(' $SRC && \
  die "step1: the host builds widgets by hand"

$SRC は host/test host/pubspec.yaml だ。host/build は外した — コンパイル成果物に文字列がそのまま埋まっていて、そこまで舐めるとこの検査は永遠に通ってしまう。実際、第4回の検査を最初に付けたときそれで誤検知した。

空の絵も絵だ

キャプチャは RepaintBoundary.toImage で撮る。画面がまったく組み上がらなくてもPNGは出る — 真っ白なPNGが。

だからファイルの有無だけを見てはいけない。

for s in step1 step2 step3 step3_alt step4 step5_empty step5_filled; do
  [ -s "captures/$s.png" ] || die "$s: no capture"
  # 組み上がらなかった画面もPNGは残す — 空のPNGを。
  [ "$(wc -c <captures/$s.png)" -gt 5000 ] || die "$s: the capture is blank"
done

step1.png は25,196バイト。白い板なら五千バイトを割る。

レンダー

step1  233 B, 4 lines, type "page" -> step1.png

1000×640論理ピクセル、2倍。淡い藤色の地の真ん中に太い黒文字。

この一文は capture_test.dart のどこにも無い。

自分で回してみる

cd content/sample/course-ui
bash verify.sh
open captures/step1.png

持ち帰るもの

  1. page → content → child — 画面は木だ
  2. initialize + buildUI — ホストの仕事はこの二つだけ
  3. 無いことを検査する — 画面の中身がプログラムに無いと確かめて初めて「画面はファイルだ」が事実になる

次回

ノード一つでは画面にならない。複数をどこに置くか — 座標なしで。

サンプルを実行する

makemind-academy/course_ui/
git clone https://github.com/makemind-academy/course_ui
cd course_ui
bash verify.sh
GitHub で開く →