Skip to content

Gomamochi

Write Go. Ship what you saw.

Gomamochi(胡麻餅)を使うと、pixie のエンジンの上で動くデスクトップアプリを Go で書けます。書いているあいだに見ていたものが、そのままリリースするものになります。そのことを確かめるのが gomamochi gate ですgomamochi run はファイルをそのまま読んで、インタプリタで動かします。ビルドの手順は要りません。保存すればウィンドウが新しいコードを取り込み、持っていた値もそのまま残ります。gomamochi build は、同じファイルを Go のコンパイラでコンパイルしてネイティブバイナリにします。どちらの実行も、pixie の C API を通して同じ描画エンジンを呼びます。そのエンジンは、Zed エディタを支える gpui の上に組んであります。cgo は使いません。gomamochi gate は一つのスクリプトで両方を動かし、描いた画面を 1 バイトずつ突き合わせます。アプリそのものは、View メソッドを持つ Go の構造体です。Gomamochi が足すのは、画面を組み立てる要素と、二つの実行が一致することを確かめる仕組みです。

全体の姿

一つのソースと、それを動かす二つの道です。

Gomamochi がアプリを動かす道筋。1 本の Go のファイルが、書いているあいだは gomamochi コマンドの中のインタプリタで動き、配るときは Go のコンパイラのバイナリになる。エンジンのライブラリは一つで、どちらも purego で開き、ゲートが二つを突き合わせる

Gomamochi がアプリを動かす道筋。1 本の Go のファイルが、書いているあいだは gomamochi コマンドの中のインタプリタで動き、配るときは Go のコンパイラのバイナリになる。エンジンのライブラリは一つで、どちらも purego で開き、ゲートが二つを突き合わせる

どちらの道も一つのエンジンに行き着きます。 pixie の C API は一つの共有ライブラリで、どちらの実行もそれを purego で開きます。 cgo は使いません。 解釈実行を担うのは、gomamochi コマンドの中の yaegi です。 コンパイルした実行は、同じファイルから go build が作ったバイナリです。 そのバイナリの横には、同じライブラリを置きます。 エンジンは Go の値を持ちません。 だからインタプリタと、コンパイルしたバイナリとが、まったく同じコードを動かせます。


書いて、動かして、配る

いちばん小さいアプリの全文です。

package main

import (
    "fmt"

    . "github.com/i2y/yokan/gomamochi"
)

type Counter struct {
    count int
}

func (c *Counter) View() Element {
    return Column(
        Text(fmt.Sprintf("count: %d", c.count)).Size(34),
        Button("+1").OnClick(func() { c.count += 1 }),
    ).Spacing(12).Padding(16)
}

func main() {
    Run(&Counter{}, Title("counter"))
}

アプリは構造体です。 状態はそのフィールド、View は要素を一つ返すメソッド、ハンドラはそのフィールドが見えるクロージャです。 継承するものも、登録するものも、監視対象だと印を付けるものもありません。 Go にはキーワード引数がないので、要素のキーワードはその要素のメソッドになっています。 Text("…").Size(34)Column(…).Spacing(12).Padding(16) のように、キーワードを並べる代わりにメソッドをつないで書きます。

dot import は好みの問題です。 パッケージに名前を付けて import すれば、呼び出しはすべて gm.Text(…)gm.Run(…) の形になります。 アプリのほうも、自分の型に好きな名前を使えます。 どちらの書き方も、両方の実行が受け取ります。

$ ./bin/gomamochi run app.go

これでウィンドウが開き、ファイルを見はじめます。 編集して保存すると、ウィンドウがそれを取り込みます。 新しいインタプリタがファイルを読み直し、ウィンドウの持っている構造体は、値をすべて保ったまま新しいコードで動きます。 ビルドの手順はありません。 Go のツールチェーンもこのループには要りません。 インタプリタはコマンドの中にあります。

配ります。

$ ./bin/gomamochi build demo/counter.go --release --app
built: demo/.gate/counter/counter (1.9 MB)
bundle: demo/dist/counter.app (22.2 MB)

バイナリは Go のコンパイラが作ったもので、cgo は使いません。 エンジンは一つの共有ライブラリとして、その横に置かれます。 --app は二つを一つのバンドルにまとめます(Linux では AppDir になり、--appimage がそれを一つのファイルに詰めます)。 受け取る人は、Go もツールチェーンも入れずに開けます。


どんな画面になるか

Gomamochi で書いた家計簿。入力欄と棒グラフと、sqlite に入っている行

demo/ledger.go。 家計簿をデータベースに置き、値は文に直接書かず ? で渡し、合計を棒グラフにしています。 普通の Go で書かれ、配るときは Go のコンパイラが作るバイナリになります。


「手元では動いたのに」

操作の並びを渡すと、解釈実行とコンパイルしたバイナリの両方でそれを再生し、できあがった画面を突き合わせます。 Gomamochi はこれをゲートと呼びます。

$ ./bin/gomamochi gate app.go --script "click:+1,dump"
GATE OK — 3 dump lines identical in both runs

コンパイルした実行は Go のコンパイラそのものです。 だからゲートが通れば、書いているあいだに見ていたウィンドウが、スクリプトの触れた範囲では配るものと一致していたということです。 違いが出るとすれば、インタプリタのほうです。 違いのある場所は、理由とともに二つの実行に挙げてあります。 どれも、何かを組み立てるより先に断ります。


Go の標準ライブラリは、両方の実行で同じコード

fmtstringsstrconvmathsorttimeencoding/jsonnet/httpos は Go 自身のものです。 これらを Gomamochi のライブラリで置き換えてはいません。 インタプリタはコマンドの中にコンパイルされたパッケージを呼び、バイナリは同じパッケージをリンクします。 だから数の書式も、整数のオーバーフローも、文字列の大文字化も同じです。 一致を確かめるための表は要りません。

    sort.Slice(order, func(a, b int) bool { return scores[order[a]] < scores[order[b]] })
    line := fmt.Sprintf("mean %.1f, %d over five", mean, len(big))

データベース、クリップボード、OS のダイアログ、音、通知はフレームワークのものです。 そちらでは、エンジンの中の一つの実装が、C API を通して両方の実行に答えます。

    SqliteExec(db, "INSERT INTO expenses VALUES (?, ?, ?)", name, strconv.Itoa(yen), cat)
    rows := SqliteQueryRowsOr(db, "SELECT name, amount, cat FROM expenses ORDER BY rowid")

移植した二つのゲーム

Pyxel 自身の例(Takashi Kitao、MIT)を二つ、ほぼ 1 行ずつ移してデモに入れてあります。 画素のキャンバスも、毎秒 30 フレームという速さも、押しっぱなしのキーの読み方も同じです。 打鍵とフレームからなるスクリプトが両方の実行を再生するので、ゲートはゲームの全フレームを突き合わせます。

demo/shooter.godemo/jump.go。 キャンバスの中で色は、配色の何番目かというだけの番号です。 だから、ドット絵の道具のために書かれた描画が、数字を変えずにそのまま移せます。


エージェントが書くとき

エージェントはファイルを書き、返ってきたものを読みます。 だから、返ってくるものの形で作業の進み方が決まります。 三つのコマンドのうち二つは、何もビルドせず、ウィンドウも開かずに、1 秒前後で答えます。 何を書けばよいかが書かれた断りと、文字になった画面です。 最後の証明がゲートです。

エージェントが回るループ。輪の中心で app.go を書き、gomamochi check とウィンドウなしの実行を 1 秒前後ずつで回り、輪の外に出て gomamochi gate で、配るバイナリが一致することを証明する

エージェントが回るループ。輪の中心で app.go を書き、gomamochi check とウィンドウなしの実行を 1 秒前後ずつで回り、輪の外に出て gomamochi gate で、配るバイナリが一致することを証明する

ループ全体はエージェントと一緒に書くにあります。


ほかに入っているもの

  • 要素はすべて一つの表から

    33 個の要素、15 個の共通キーワード、10 個の描画命令が、elements.toml に一度だけ書いてあります。 アプリが呼ぶ Go も、インタプリタから見えるパッケージも、エンジンが数える番号も、そこから生成されます。 だから一つの要素が二つの意味を持つことはありません。 このエンジンの上のほかの三つの言語も、同じ表を読んでいます。

  • キャンバスとキーボード

    仮想的な画素の格子を、命令をひとつずつ並べて塗ります。 色は配色の番号で指し、キーが押されているかどうかはタイマーの中で読みます。 音は AudioPlay で WAV を鳴らします。 ウィンドウを開かずに、どのフレームも PNG に書き出せます。

  • 教える断り方

    コンパイルした実行と同じにはインタプリタが動かせない書き方は、何かを組み立てるより先に断ります。 断りには、その行と、代わりにどう書くかが出ます。 Go 自身のエラーは、Go 自身の言葉で返ります。 どの断りにも、出力される文面をそのまま置いたファイルがあります。


できないこと

  • 解釈実行は yaegi で、その Go は 1.22 のものです。 minmax、数や関数を回す range、そして 1.22 より後に標準ライブラリに入ったものはありません。 最初の三つは、書き直し方とともに断ります。 全体の一覧はツアーの最後の節にあります。
  • アプリは 1 ファイルで、import できるのは標準ライブラリとこのパッケージです。 標準ライブラリの外のモジュールは断ります。 インタプリタがまだ読めないからです。
  • 配るアプリは、バイナリと、その横に置くエンジンのライブラリの二つです。 この二つをまとめたバンドルでもかまいません。 1 本のファイルにまとめる形はありません。
  • dot import のもとでは、パッケージが公開している名前(AppElementTextRun など)はすでに埋まっています。 アプリ自身の型には、それを避けた名前を付けます。 パッケージに名前を付けて import してもかまいません。
  • Apple シリコンの macOS と Linux。

理由は、ツアーのまだできないことにあります。


次に読むもの

  • インストール

    必要なもの、一度だけの用意、そして四つのコマンド。 Apple シリコンの macOS と Linux です。

  • 言語ツアー

    アプリの書き方をひととおり。 状態、ビュー、キャンバス、ウィンドウ、データベース、ゲート。 最後はまだできないことで閉じます。

  • デモ

    44 本のアプリを、画面写真とソース全体つきで。

  • ソース

    コマンドと、エンジンを開くパッケージと、エンジンと、デモ。


名前は胡麻餅、黒胡麻を練り込んだ餅です。 エンジンを共にする羊羹と若草と落雁と同じく、和菓子から採りました。