Skip to content

Go, data, and work

Go's own library, the framework's, timers, work off the window's thread, and the window that follows your saves.

Go's own standard library

fmt, strings, strconv, sort, math, time, encoding/json, encoding/csv, regexp, os, net/http: the language's own, and both runs call the same compiled packages. The interpreter does not reimplement them — it calls the ones compiled into the command — so what fmt.Sprintf("%.2f", x) answers is the same bytes in both runs, and the gate never has to compare two implementations of a library.

package main

import (
    "encoding/json"
    "fmt"
    "regexp"
    "sort"
    "strconv"

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

type Stdlib struct {
    scores []int
    spread string
    doc    string
    sum    int
}

func (s *Stdlib) stats() {
    sorted := append([]int{}, s.scores...)
    sort.Ints(sorted)
    s.spread = fmt.Sprintf("median %d min %d max %d", sorted[len(sorted)/2], sorted[0], sorted[len(sorted)-1])
}

func (s *Stdlib) parse() {
    var doc map[string]any
    json.Unmarshal([]byte(`{"name": "gomamochi", "ok": true}`), &doc)
    s.doc = fmt.Sprintf("%v %v", doc["name"], doc["ok"])
}

func (s *Stdlib) scan() {
    s.sum = 0
    for _, m := range regexp.MustCompile(`\d+`).FindAllString("a1b22c333", -1) {
        v, _ := strconv.Atoi(m)
        s.sum += v
    }
}

func (s *Stdlib) View() Element {
    return Column(
        Text("spread: "+s.spread),
        Text("json: "+s.doc),
        Text(fmt.Sprintf("scan: %d", s.sum)),
        Row(
            Button("stats").OnClick(func() { s.stats() }),
            Button("parse").OnClick(func() { s.parse() }),
            Button("scan").OnClick(func() { s.scan() }),
        ).Spacing(6),
    ).Spacing(6).Padding(14)
}

func main() {
    Run(&Stdlib{scores: []int{3, 5, 8, 13, 21}, spread: "-", doc: "-"}, Title("stdlib"))
}

What the interpreter knows of the library is Go 1.22's: the packages and functions of that release. Of the language it knows less: min and max (1.21), range over a number (1.22) and over a function (1.23) are not there, nor is anything the library gained in 1.23 or later, and check says so by name for the first three. Files are os, the network is net/http, a big number is math/big; the demos read a file, serve a page to themselves and fetch it, and parse CSV, with nothing but the standard library.

The framework's standard library

What the engine mediates comes from the package, and there one implementation answers both runs, through the same C face: a scripted run keeps a dialog answered by file:<path> and a sound silent in both.

Function What it does
SqliteExec(db, sql, params...) run a statement; answers the rows changed
SqliteQueryText, SqliteQueryInt, SqliteQueryRows a query's first column, first cell, or whole rows
SqliteQueryTextOr, SqliteQueryIntOr, SqliteQueryRowsOr the same, answering nothing rather than stopping when the query fails
ClipboardSetText(s), ClipboardGetText() the system clipboard
OpenDialog(title), SaveDialog(name) the platform's own panels; they wait, so call them inside a Task
AudioPlay(path, volume), AudioStop() a WAV file, played and forgotten
NotifySend(title, body) a notification

Write ? in a statement and put the values after it, and text a person typed can never become part of the statement:

package main

import (
    "fmt"

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

const db = "demo/.gate/tour-notes.db"

type Notes struct {
    changed int
    rows    []string
}

func (nt *Notes) setup() {
    SqliteExec(db, "CREATE TABLE IF NOT EXISTS notes(t TEXT)")
    SqliteExec(db, "DELETE FROM notes")
    nt.changed = SqliteExec(db, "INSERT INTO notes VALUES (?), (?)", "alpha", "beta")
}

func (nt *Notes) View() Element {
    return Column(
        Text(fmt.Sprintf("inserted=%d rows=%d", nt.changed, len(nt.rows))),
        Row(
            Button("setup").OnClick(func() { nt.setup() }),
            Button("load").OnClick(func() { nt.rows = SqliteQueryText(db, "SELECT t FROM notes ORDER BY t") }),
        ).Spacing(6),
        ListView(len(nt.rows), func(i int) Element { return Text(nt.rows[i]) }).ItemHeight(22).Height(80),
    ).Spacing(8).Padding(12)
}

func main() {
    Run(&Notes{}, Title("notes"))
}

A query that may run before its table exists — the first load of a ledger does — is the …Or twin, which answers nothing; the plain one panics, as the library's own would, with the library's message as the panic's value: a recover in the handler receives it, and without one the app stops there.

Timers and work off the window's thread

Every(seconds, tick) asks to be told every so often, declared before Run. Both runs tick off the same clock: a frame in a window, and advance:<ms> in a script, so the same number of ticks lands in both. A tick is where a game moves and where the keyboard is read.

Task(work, done) runs work on a goroutine of its own and, when it is done, calls done on the window's thread with what the work answered. Nothing inside the work touches the app's fields or the screen; that is the whole rule, and the reason the answer comes back as an argument rather than the worker writing it anywhere.

package main

import (
    "fmt"

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

type Jobs struct {
    status string
    answer int
}

func (j *Jobs) start() {
    j.status = "working"
    Task(func() any {
        total := 0
        for i := 0; i < 300000; i++ {
            total += i % 7
        }
        return total
    }, func(v any) {
        j.answer = v.(int)
        j.status = "done"
    })
}

func (j *Jobs) View() Element {
    return Column(
        Text(fmt.Sprintf("%s: %d", j.status, j.answer)),
        Button("start").OnClick(func() { j.start() }),
    ).Spacing(10).Padding(14)
}

func main() {
    Run(&Jobs{status: "idle"}, Title("tasks"))
}

A script settles the work before its next step, so dump after click:start shows the answer in both runs. The work may call the framework's library — a dialog, a query — from its goroutine, or from any goroutine the app starts itself: each call keeps to one thread for its own duration, and nothing in the app has to know.

While you are writing it

gomamochi run watches the app's file. Save, and the window picks the edit up: the file is read again by a fresh interpreter, and the app the window is holding hands its values to the new one — every field with the same name and type keeps what it had, a field the new file adds starts at what main gives it, and a field whose type changed starts over. Timers and shortcuts the file declares are bound again to the new app. A file that does not compile leaves the window on what it had and says so in the terminal, and the next save that does compile takes.

A save is a fresh read of the whole file, and main runs again. The starting values it gives the app do not replace the ones the window has, since those are carried over — which is the point.