From 1147b0120872ef881bdee50c02fe18c138af6fca Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Sat, 16 May 2026 18:07:51 +0200 Subject: [PATCH 1/8] wip: refactor --- .golangci.yml | 61 ++++++++-------- README.md | 181 ++++++++++++++++++++++++++++++++++++++++++++++- canvas.go | 133 ++++++++++++++++++++++++++++++++++ codec.go | 145 +++++++++++++++++++++++++++++++++++++ doc.go | 2 +- go.mod | 7 +- go.sum | 6 -- http_wasip1.go | 150 +++++++++++++++++++++++++++++++++++++++ logger.go | 45 ------------ logger_wasip1.go | 61 ++++++++++++++++ module.go | 97 ------------------------- module_wasip1.go | 67 ++++++++++++++++++ widget.go | 173 ++++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 943 insertions(+), 185 deletions(-) create mode 100644 canvas.go create mode 100644 codec.go create mode 100644 http_wasip1.go delete mode 100644 logger.go create mode 100644 logger_wasip1.go delete mode 100644 module.go create mode 100644 module_wasip1.go create mode 100644 widget.go diff --git a/.golangci.yml b/.golangci.yml index 36ca989..6928147 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,48 +1,51 @@ +version: "2" run: tests: false - deadline: 5m -linters-settings: - gofumpt: - extra-rules: true +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gofumpt: + extra-rules: true + exclusions: + generated: lax linters: - enable-all: true + default: all disable: - - interfacebloat - - sqlclosecheck # not relevant (SQL) - - rowserrcheck # not relevant (SQL) - - execinquery # not relevant (SQL) - - interfacer # deprecated - - scopelint # deprecated - - maligned # deprecated - - golint # deprecated - - deadcode # deprecated - - exhaustivestruct # deprecated - - ifshort # deprecated - - nosnakecase # deprecated - - structcheck # deprecated - - varcheck # deprecated - - cyclop # duplicate of gocyclo - depguard + - err113 - exhaustive - exhaustruct - forcetypeassert + - funcorder - funlen - gochecknoglobals - gochecknoinits - - gocognit - - gocyclo - - goerr113 - - gomnd + - gomoddirectives - ireturn - - nestif + - mnd + - musttag + - nilerr - nlreturn + - noinlineerr - nonamedreturns - - tagliatelle - varnamelen - wrapcheck - wsl - -issues: - exclude-use-default: false + - wsl_v5 + settings: + cyclop: + max-complexity: 20 + gosec: + excludes: + - G302 + - G306 + lll: + line-length: 160 + exclusions: + generated: lax diff --git a/README.md b/README.md index a496b32..a4d5c53 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,185 @@ [![GitHub release](https://img.shields.io/github/release/glasslabs/client-go.svg)](https://github.com/glasslabs/client-go/releases) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/glasslabs/client-go/main/LICENSE) -`client-go` bridges the gap between WASM modules and looking glass. +`client-go` is the Go library for writing [looking-glass](https://github.com/glasslabs/looking-glass) modules. Modules are compiled to WebAssembly (`GOARCH=wasm GOOS=wasip1`) and run inside the looking-glass host. This library provides everything a module needs: identity, configuration, asset access, rendering, HTTP, and logging. **Note:** This is in active development, the API may change. + +--- + +## How a module works + +A looking-glass module is a standard Go `main` package compiled to a WASI binary. The host launches one instance per module slot, injects configuration via environment variables, mounts an assets directory, and exposes a small set of host functions for rendering and HTTP. The module runs its own event loop and calls `mod.Content(widget)` whenever the display should update. + +## Writing a module + +### 1. Initialise the module + +`client.NewModule()` reads the `MODULE_NAME` environment variable set by the host. It must be called before any other client functions that require the module identity. + +```go +//go:build wasip1 + +package main + +import "github.com/glasslabs/client-go" + +func main() { + log := client.NewLogger() + + mod, err := client.NewModule() + if err != nil { + log.Error("Could not create module", "error", err.Error()) + return + } + // ... +} +``` + +### 2. Parse configuration + +The host encodes the module's configuration map as JSON and exposes it via `MODULE_CONFIG`. Call `mod.ParseConfig` with a pointer to your config struct; fields are populated from the JSON keys that match the struct tags. Set defaults before calling `ParseConfig` so that omitted keys retain sensible values. + +```go +type Config struct { + Interval int `json:"interval"` + Label string `json:"label"` +} + +cfg := Config{Interval: 60} // set defaults first +if err := mod.ParseConfig(&cfg); err != nil { + log.Error("Could not parse config", "error", err.Error()) + return +} +``` + +### 3. Load assets + +Static files (templates, images, SVG) are placed alongside the module binary. The host mounts them at `/assets` inside the WASM sandbox. Use `mod.Asset(path)` to read them; `path` is relative to that directory. + +```go +data, err := mod.Asset("index.html") +``` + +### 4. Render content + +Call `mod.Content(widget)` to push a new widget tree to the display. The host replaces the module's current content with the new tree on every call — there is no diffing. Call it once on startup and again whenever the display needs to change. + +Widget trees are built from the composable types in this package: + +| Widget | Behaviour | +|--------|-----------| +| `NewText(s, ...opts)` | A single styled line of text. | +| `NewSVG(markup)` | Raw SVG markup rasterised into the slot. | +| `NewVStack(children...)` | Lays out children vertically, top to bottom. | +| `NewHStack(children...)` | Lays out children horizontally, left to right. | +| `NewSpacer()` | Flexible empty space that fills remaining room in a stack. | +| `NewCanvas(w, h, ops...)` | A fixed logical viewport scaled to fit, drawn with `DrawOp` commands. | + +`Text` can be styled with option functions: `WithColor`, `WithFontSize`, `WithLight`, `WithBold`, `WithItalic`, `WithCondensed`, and `WithAlign`. + +```go +func render(mod *client.Module, label string) { + mod.Content(client.NewVStack( + client.NewText(label, + client.WithColor("#ffffff"), + client.WithFontSize(48), + client.WithLight(), + ), + client.NewSpacer(), + )) +} +``` + +#### Canvas drawing + +`Canvas` renders a list of `DrawOp` commands within a logical coordinate space. The viewport is scaled uniformly to fit the allocated slot. + +| DrawOp | Behaviour | +|--------|-----------| +| `NewRect(x, y, w, h, ...opts)` | Filled and/or stroked rectangle. Options: `WithFill`, `WithStroke`, `WithCornerRadius`. | +| `NewArc(cx, cy, radius, startAngle, sweepAngle, strokeWidth, color)` | Circular arc stroke. Angles are in degrees; 0° is right, clockwise positive. | +| `NewLabel(x, y, align, runs...)` | Baseline-aligned multi-run text. `align` is `"start"`, `"middle"`, or `"end"`. | +| `NewPath(x, y, scale, d, fill)` | SVG path `d` string placed at an offset with optional uniform scale. | + +`Label` is built from `TextRun` segments created with `NewRun(content, ...opts)`. Options: `WithRunFontSize`, `WithRunBaselineShift`, `WithRunColor`. + +### 5. Make HTTP requests + +The host provides an HTTP transport that is automatically installed into `http.DefaultClient` when the module starts. Use the standard `net/http` package directly — no special client is required. + +```go +resp, err := http.Get("https://example.com/api/data") +``` + +The transport supports streaming response bodies, so Server-Sent Events and other long-lived streams work as expected. + +### 6. Log messages + +`client.NewLogger()` returns a logger that writes logfmt lines to stderr. The host captures these lines and forwards them to its structured logging pipeline. + +```go +log := client.NewLogger() +log.Info("Module ready", "module", mod.Name()) +log.Error("Something failed", "error", err.Error()) +``` + +Methods: `Debug`, `Info`, `Warn`, `Error`. Each accepts a message followed by alternating key/value string pairs. + +--- + +## Building a module + +Modules must be compiled for the `wasip1` target: + +```bash +GOARCH=wasm GOOS=wasip1 go build -o my-module.wasm . +``` + +All source files that use `client-go` should carry the `//go:build wasip1` build constraint, as the host functions are only available inside the WASM sandbox. + +## Minimal example + +```go +//go:build wasip1 + +package main + +import ( + "time" + + "github.com/glasslabs/client-go" +) + +type Config struct { + Format string `json:"format"` +} + +func main() { + log := client.NewLogger() + + mod, err := client.NewModule() + if err != nil { + log.Error("Could not create module", "error", err.Error()) + return + } + + cfg := Config{Format: "15:04:05"} + if err = mod.ParseConfig(&cfg); err != nil { + log.Error("Could not parse config", "error", err.Error()) + return + } + + log.Info("Module ready", "module", mod.Name()) + + for { + mod.Content(client.NewText( + time.Now().Format(cfg.Format), + client.WithColor("#ffffff"), + client.WithFontSize(48), + )) + time.Sleep(time.Second) + } +} +``` + diff --git a/canvas.go b/canvas.go new file mode 100644 index 0000000..c0cdb0c --- /dev/null +++ b/canvas.go @@ -0,0 +1,133 @@ +package client + +import "encoding/xml" + +// DrawOp is a drawing command within a Canvas. +type DrawOp interface { + drawOp() +} + +// Arc draws a circular arc stroke. +type Arc struct { + XMLName xml.Name `xml:"arc"` + Cx float32 `xml:"cx,attr"` + Cy float32 `xml:"cy,attr"` + Radius float32 `xml:"radius,attr"` + StartAngle float32 `xml:"startAngle,attr"` // degrees; 0 = right, clockwise + SweepAngle float32 `xml:"sweepAngle,attr"` // degrees; clockwise positive + StrokeWidth float32 `xml:"strokeWidth,attr"` + Color string `xml:"color,attr"` +} + +// Rect draws a filled and/or stroked rectangle. +type Rect struct { + XMLName xml.Name `xml:"rect"` + X float32 `xml:"x,attr"` + Y float32 `xml:"y,attr"` + W float32 `xml:"w,attr"` + H float32 `xml:"h,attr"` + CornerRadius float32 `xml:"cornerRadius,attr,omitempty"` + Fill string `xml:"fill,attr,omitempty"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth float32 `xml:"strokeWidth,attr,omitempty"` +} + +// Label draws baseline-aligned multi-run text at a canvas position. +type Label struct { + XMLName xml.Name `xml:"label"` + X float32 `xml:"x,attr"` + Y float32 `xml:"y,attr"` + Align string `xml:"align,attr,omitempty"` // "start", "middle", or "end" + Runs []*TextRun `xml:"run"` +} + +// TextRun is a single styled text segment within a Label. +type TextRun struct { + XMLName xml.Name `xml:"run"` + Content string `xml:",chardata"` + FontSize float32 `xml:"fontSize,attr,omitempty"` + BaselineShift float32 `xml:"baselineShift,attr,omitempty"` + Color string `xml:"color,attr,omitempty"` +} + +// Path draws an SVG path d-string at an offset with optional uniform scale. +type Path struct { + XMLName xml.Name `xml:"path"` + X float32 `xml:"x,attr,omitempty"` + Y float32 `xml:"y,attr,omitempty"` + Scale float32 `xml:"scale,attr,omitempty"` + D string `xml:"d,attr"` + Fill string `xml:"fill,attr,omitempty"` +} + +func (*Arc) drawOp() {} +func (*Rect) drawOp() {} +func (*Label) drawOp() {} +func (*Path) drawOp() {} + +// RectOption configures a Rect draw operation. +type RectOption func(*Rect) + +// WithFill sets the rectangle fill colour. +func WithFill(c string) RectOption { return func(r *Rect) { r.Fill = c } } + +// WithStroke sets the rectangle stroke colour and width. +func WithStroke(c string, w float32) RectOption { + return func(r *Rect) { r.Stroke = c; r.StrokeWidth = w } +} + +// WithCornerRadius sets the corner radius for a rounded rectangle. +func WithCornerRadius(cr float32) RectOption { return func(r *Rect) { r.CornerRadius = cr } } + +// RunOption configures a TextRun within a Label. +type RunOption func(*TextRun) + +// WithRunFontSize sets the run font size in canvas units. +func WithRunFontSize(s float32) RunOption { return func(r *TextRun) { r.FontSize = s } } + +// WithRunBaselineShift shifts the run down from the shared baseline in canvas units. +func WithRunBaselineShift(s float32) RunOption { return func(r *TextRun) { r.BaselineShift = s } } + +// WithRunColor sets the run text colour as an HTML hex string. +func WithRunColor(c string) RunOption { return func(r *TextRun) { r.Color = c } } + +// NewArc returns an Arc draw operation. +func NewArc(cx, cy, radius, startAngle, sweepAngle, strokeWidth float32, color string) *Arc { + return &Arc{ + Cx: cx, + Cy: cy, + Radius: radius, + StartAngle: startAngle, + SweepAngle: sweepAngle, + StrokeWidth: strokeWidth, + Color: color, + } +} + +// NewRect returns a Rect draw operation. +func NewRect(x, y, w, h float32, opts ...RectOption) *Rect { + r := &Rect{X: x, Y: y, W: w, H: h} + for _, o := range opts { + o(r) + } + return r +} + +// NewLabel returns a Label draw operation. +func NewLabel(x, y float32, align string, runs ...*TextRun) *Label { + return &Label{X: x, Y: y, Align: align, Runs: runs} +} + +// NewRun returns a TextRun for use inside a Label. +func NewRun(content string, opts ...RunOption) *TextRun { + r := &TextRun{Content: content} + for _, o := range opts { + o(r) + } + return r +} + +// NewPath returns a Path draw operation. +func NewPath(x, y, scale float32, d, fill string) *Path { + return &Path{X: x, Y: y, Scale: scale, D: d, Fill: fill} +} diff --git a/codec.go b/codec.go new file mode 100644 index 0000000..d513717 --- /dev/null +++ b/codec.go @@ -0,0 +1,145 @@ +package client + +import ( + "bytes" + "encoding/xml" + "fmt" + "strconv" +) + +// widgetByTag maps XML element names to factory functions for Widget implementations. +// Unknown tags are skipped by callers; add new kinds here to extend the protocol. +var widgetByTag = map[string]func() Widget{ + "text": func() Widget { return &Text{} }, + "svg": func() Widget { return &SVG{} }, + "vstack": func() Widget { return &VStack{} }, + "hstack": func() Widget { return &HStack{} }, + "spacer": func() Widget { return &Spacer{} }, + "canvas": func() Widget { return &Canvas{} }, +} + +// drawOpByTag maps XML element names to factory functions for DrawOp implementations. +var drawOpByTag = map[string]func() DrawOp{ + "arc": func() DrawOp { return &Arc{} }, + "rect": func() DrawOp { return &Rect{} }, + "label": func() DrawOp { return &Label{} }, + "path": func() DrawOp { return &Path{} }, +} + +// DecodeWidget decodes an XML-encoded Widget from b. +// The root element tag determines the concrete Widget type. +// Unknown root tags return an error; unknown child tags are silently skipped. +func DecodeWidget(b []byte) (Widget, error) { + d := xml.NewDecoder(bytes.NewReader(b)) + for { + tok, err := d.Token() + if err != nil { + return nil, fmt.Errorf("decoding widget: %w", err) + } + start, ok := tok.(xml.StartElement) + if !ok { + continue + } + factory, ok := widgetByTag[start.Name.Local] + if !ok { + return nil, fmt.Errorf("unknown widget kind %q", start.Name.Local) + } + w := factory() + if err = d.DecodeElement(w, &start); err != nil { + return nil, fmt.Errorf("decoding %s: %w", start.Name.Local, err) + } + return w, nil + } +} + +// UnmarshalXML decodes the VStack element and its Widget children. +func (v *VStack) UnmarshalXML(d *xml.Decoder, _ xml.StartElement) error { + return decodeWidgetChildren(d, func(child Widget) { + v.Children = append(v.Children, child) + }) +} + +// UnmarshalXML decodes the HStack element and its Widget children. +func (h *HStack) UnmarshalXML(d *xml.Decoder, _ xml.StartElement) error { + return decodeWidgetChildren(d, func(child Widget) { + h.Children = append(h.Children, child) + }) +} + +// UnmarshalXML decodes the Canvas element, its width/height attributes, and DrawOp children. +func (c *Canvas) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for _, attr := range start.Attr { + switch attr.Name.Local { + case "width": + f, _ := strconv.ParseFloat(attr.Value, 32) + c.Width = float32(f) + case "height": + f, _ := strconv.ParseFloat(attr.Value, 32) + c.Height = float32(f) + } + } + return decodeDrawOpChildren(d, func(op DrawOp) { + c.Ops = append(c.Ops, op) + }) +} + +// decodeWidgetChildren reads child elements from d, instantiates a Widget for each +// known tag, and calls add for each decoded child. Unknown tags are skipped. +func decodeWidgetChildren(d *xml.Decoder, add func(Widget)) error { + for { + tok, err := d.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + factory, ok := widgetByTag[t.Name.Local] + if !ok { + if err = d.Skip(); err != nil { + return err + } + continue + } + child := factory() + if err = d.DecodeElement(child, &t); err != nil { + return err + } + add(child) + case xml.EndElement: + return nil + } + } +} + +// decodeDrawOpChildren reads child elements from d, instantiates a DrawOp for each +// known tag, and calls add for each decoded op. Unknown tags are skipped. +func decodeDrawOpChildren(d *xml.Decoder, add func(DrawOp)) error { + for { + tok, err := d.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + factory, ok := drawOpByTag[t.Name.Local] + if !ok { + if err = d.Skip(); err != nil { + return err + } + continue + } + op := factory() + if err = d.DecodeElement(op, &t); err != nil { + return err + } + add(op) + case xml.EndElement: + return nil + } + } +} + +// formatFloat32 formats a float32 as a decimal string with no trailing zeros. +func formatFloat32(f float32) string { + return strconv.FormatFloat(float64(f), 'f', -1, 32) +} diff --git a/doc.go b/doc.go index 29e23b4..e69c68c 100644 --- a/doc.go +++ b/doc.go @@ -1,2 +1,2 @@ -// Package client provides bridging functions between WASM modules and looking glass. +// Package client is the client library for writing looking-glass WASM modules. package client diff --git a/go.mod b/go.mod index 2763a38..89215f3 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,3 @@ module github.com/glasslabs/client-go -go 1.21.3 - -require ( - gopkg.in/yaml.v3 v3.0.1 - honnef.co/go/js/dom/v2 v2.0.0-20231112215516-51f43a291193 -) +go 1.26 diff --git a/go.sum b/go.sum index 653770e..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +0,0 @@ -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/js/dom/v2 v2.0.0-20231112215516-51f43a291193 h1:qc3CbLs/1Tb3N7yIKKph/UbUoyI8YQfTF1apIvqX/I8= -honnef.co/go/js/dom/v2 v2.0.0-20231112215516-51f43a291193/go.mod h1:+JtEcbinwR4znM12aluJ3WjKgvhDPKPQ8hnP4YM+4jI= diff --git a/http_wasip1.go b/http_wasip1.go new file mode 100644 index 0000000..9d4ccd4 --- /dev/null +++ b/http_wasip1.go @@ -0,0 +1,150 @@ +//go:build wasip1 + +package client + +import ( + "fmt" + "io" + "net/http" + "strings" + "unsafe" +) + +// emptyBuf provides a valid non-nil pointer for optional host function parameters +// when the caller supplies no data. +var emptyBuf [1]byte + +func init() { + t := &hostTransport{} + http.DefaultTransport = t + http.DefaultClient = &http.Client{Transport: t} +} + +//go:wasmimport looking-glass http_stream_open +func hostHTTPStreamOpen( + methodPtr unsafe.Pointer, methodLen uint32, + urlPtr unsafe.Pointer, urlLen uint32, + hdrPtr unsafe.Pointer, hdrLen uint32, + bodyPtr unsafe.Pointer, bodyLen uint32, +) int32 + +//go:wasmimport looking-glass http_stream_status +func hostHTTPStreamStatus(handle int32) uint32 + +//go:wasmimport looking-glass http_stream_read +func hostHTTPStreamRead(handle int32, bufPtr unsafe.Pointer, bufLen uint32) int32 + +//go:wasmimport looking-glass http_stream_close +func hostHTTPStreamClose(handle int32) + +// hostTransport implements http.RoundTripper by routing every request through +// the looking-glass host. It supports both short request/response exchanges and +// indefinitely long response streams such as Server-Sent Events. +type hostTransport struct{} + +// RoundTrip implements http.RoundTripper. +func (t *hostTransport) RoundTrip(req *http.Request) (*http.Response, error) { + method := req.Method + if method == "" { + method = http.MethodGet + } + methodBytes := []byte(method) + + urlBytes := []byte(req.URL.String()) + + // Headers are serialised as "Key: Value\n" lines for the host to parse. + var hdrSB strings.Builder + for k, vs := range req.Header { + for _, v := range vs { + hdrSB.WriteString(k) + hdrSB.WriteString(": ") + hdrSB.WriteString(v) + hdrSB.WriteByte('\n') + } + } + hdrBytes := []byte(hdrSB.String()) + + var bodyBytes []byte + if req.Body != nil { + var err error + bodyBytes, err = io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return nil, fmt.Errorf("reading request body: %w", err) + } + } + + // Always pass a valid Go pointer; the host ignores zero-length slices. + methodPtr := unsafe.Pointer(&methodBytes[0]) + urlPtr := unsafe.Pointer(&urlBytes[0]) + + hdrPtr := unsafe.Pointer(&emptyBuf[0]) + hdrLen := uint32(0) + if len(hdrBytes) > 0 { + hdrPtr = unsafe.Pointer(&hdrBytes[0]) + hdrLen = uint32(len(hdrBytes)) + } + + bodyPtr := unsafe.Pointer(&emptyBuf[0]) + bodyLen := uint32(0) + if len(bodyBytes) > 0 { + bodyPtr = unsafe.Pointer(&bodyBytes[0]) + bodyLen = uint32(len(bodyBytes)) + } + + handle := hostHTTPStreamOpen( + methodPtr, uint32(len(methodBytes)), + urlPtr, uint32(len(urlBytes)), + hdrPtr, hdrLen, + bodyPtr, bodyLen, + ) + if handle < 0 { + return nil, fmt.Errorf("http: open stream for %s %s failed (errno %d)", method, req.URL, -handle) + } + + status := int(hostHTTPStreamStatus(handle)) + return &http.Response{ + StatusCode: status, + Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Body: &streamBody{handle: handle}, + Request: req, + }, nil +} + +// streamBody is an io.ReadCloser backed by a host stream handle. +type streamBody struct { + handle int32 + closed bool +} + +// Read implements io.Reader. Returns io.EOF when the response body is exhausted. +func (b *streamBody) Read(p []byte) (int, error) { + if b.closed { + return 0, io.EOF + } + if len(p) == 0 { + return 0, nil + } + n := hostHTTPStreamRead(b.handle, unsafe.Pointer(&p[0]), uint32(len(p))) + if n < 0 { + return 0, fmt.Errorf("stream read error (errno %d)", -n) + } + if n == 0 { + return 0, io.EOF + } + return int(n), nil +} + +// Close implements io.Closer. +func (b *streamBody) Close() error { + if !b.closed { + b.closed = true + hostHTTPStreamClose(b.handle) + } + return nil +} + diff --git a/logger.go b/logger.go deleted file mode 100644 index 271b12e..0000000 --- a/logger.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build js && wasm - -package client - -import ( - "os" //nolint:gci - "syscall/js" -) - -// Logger binds to the looking glass logger in the browser. -type Logger struct { - name string -} - -// NewLogger returns a new logger. -func NewLogger() *Logger { - name := os.Args[0] - - return &Logger{ - name: name, - } -} - -// Debug writes a debug log message. -func (l *Logger) Debug(msg string, kvs ...string) { - js.Global().Call("log.debug", msg, toSliceAny(kvs)) -} - -// Info writes a info log message. -func (l *Logger) Info(msg string, kvs ...string) { - js.Global().Call("log.info", msg, toSliceAny(kvs)) -} - -// Error writes a error log message. -func (l *Logger) Error(msg string, kvs ...string) { - js.Global().Call("log.error", msg, toSliceAny(kvs)) -} - -func toSliceAny[T any](a []T) []any { - arr := make([]any, len(a)) - for i, s := range a { - arr[i] = s - } - return arr -} diff --git a/logger_wasip1.go b/logger_wasip1.go new file mode 100644 index 0000000..91b5cb3 --- /dev/null +++ b/logger_wasip1.go @@ -0,0 +1,61 @@ +//go:build wasip1 + +package client + +import ( + "fmt" + "os" + "strings" +) + +// Logger writes structured log messages to the host. +type Logger struct { + name string +} + +// NewLogger returns a new Logger. +func NewLogger() *Logger { + return &Logger{name: os.Getenv("MODULE_NAME")} +} + +// Debug writes a debug log message. +func (l *Logger) Debug(msg string, kvs ...string) { + fmt.Fprintln(os.Stderr, l.format("debug", msg, kvs)) +} + +// Info writes an info log message. +func (l *Logger) Info(msg string, kvs ...string) { + fmt.Fprintln(os.Stderr, l.format("info", msg, kvs)) +} + +// Warn writes a warn log message. +func (l *Logger) Warn(msg string, kvs ...string) { + fmt.Fprintln(os.Stderr, l.format("warn", msg, kvs)) +} + +// Error writes an error log message. +func (l *Logger) Error(msg string, kvs ...string) { + fmt.Fprintln(os.Stderr, l.format("error", msg, kvs)) +} + +func (l *Logger) format(level, msg string, kvs []string) string { + var b strings.Builder + b.WriteString("level=") + b.WriteString(level) + b.WriteString(" module=") + b.WriteString(l.name) + b.WriteString(" msg=") + b.WriteString(fmt.Sprintf("%q", msg)) + for i := 0; i+1 < len(kvs); i += 2 { + b.WriteByte(' ') + b.WriteString(kvs[i]) + b.WriteByte('=') + val := kvs[i+1] + if strings.ContainsAny(val, " \t\r\n\"") { + b.WriteString(fmt.Sprintf("%q", val)) + } else { + b.WriteString(val) + } + } + return b.String() +} diff --git a/module.go b/module.go deleted file mode 100644 index 2c63a17..0000000 --- a/module.go +++ /dev/null @@ -1,97 +0,0 @@ -//go:build js && wasm - -package client - -import ( - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - - "gopkg.in/yaml.v3" - "honnef.co/go/js/dom/v2" -) - -// Module bridges the gap between WASM modules and looking glass. -type Module struct { - name string - root dom.Element -} - -// NewModule returns a module. -func NewModule() (*Module, error) { - name := os.Args[0] - - root := dom.GetWindow().Document().QuerySelector("#" + name + ".module") - if root == nil { - return nil, fmt.Errorf("module %q not found", name) - } - - return &Module{ - name: name, - root: root, - }, nil -} - -// Name returns the module name. -func (m *Module) Name() string { - return m.name -} - -// ParseConfig parse the config for the module into v. -func (m *Module) ParseConfig(v any) error { - if len(os.Args) <= 1 { - return nil - } - - return yaml.Unmarshal([]byte(os.Args[1]), v) -} - -// Asset returns the path in the configured asset directory. -func (m *Module) Asset(path string) ([]byte, error) { - assetPath := os.Getenv("ASSETS_URL") - u, err := url.Parse(assetPath) - if err != nil { - return nil, fmt.Errorf("parsing assest path %q: %w", assetPath, err) - } - u = u.JoinPath(path) - - //nolint:noctx // There is not context here anyway. - resp, err := http.DefaultClient.Get(u.String()) - if err != nil { - return nil, fmt.Errorf("getting asset: %w", err) - } - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - return io.ReadAll(resp.Body) -} - -// LoadCSS loads the given styles into the module. -func (m *Module) LoadCSS(styles ...string) error { - for _, style := range styles { - styleElem := dom.GetWindow().Document().CreateElement("style") - styleElem.SetID(m.name) - styleElem.SetTextContent(style) - - headElem := dom.GetWindow().Document().QuerySelector("head") - if headElem == nil { - return errors.New("head element not found") - } - headElem.AppendChild(styleElem) - } - return nil -} - -// Element returns the modules root DOM element. -func (m *Module) Element() dom.Element { - return m.root -} diff --git a/module_wasip1.go b/module_wasip1.go new file mode 100644 index 0000000..f763041 --- /dev/null +++ b/module_wasip1.go @@ -0,0 +1,67 @@ +//go:build wasip1 + +package client + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "os" + "unsafe" +) + +//go:wasmimport looking-glass render +func hostRender(ptr unsafe.Pointer, length uint32) + +// Module represents a running looking-glass module instance. +type Module struct { + name string +} + +// NewModule returns a new Module. The module name is read from the +// MODULE_NAME environment variable set by the host. +func NewModule() (*Module, error) { + name := os.Getenv("MODULE_NAME") + if name == "" { + return nil, fmt.Errorf("MODULE_NAME not set") + } + + return &Module{name: name}, nil +} + +// Name returns the module name. +func (m *Module) Name() string { + return m.name +} + +// ParseConfig decodes the host-provided configuration into v. +// If no configuration was provided, v is left unchanged. +func (m *Module) ParseConfig(v any) error { + cfgStr := os.Getenv("MODULE_CONFIG") + if cfgStr == "" { + return nil + } + + return json.Unmarshal([]byte(cfgStr), v) +} + +// Asset returns the contents of the named file from the module's assets directory. +func (m *Module) Asset(path string) ([]byte, error) { + return os.ReadFile("/assets/" + path) +} + +// Render sends a widget tree to the display. The host replaces the current +// content with the new tree on every call. +func (m *Module) Render(w Widget) { + data, err := xml.Marshal(w) + if err != nil { + return + } + + payload := make([]byte, len(m.name)+1+len(data)) + copy(payload, m.name) + payload[len(m.name)] = 0 + copy(payload[len(m.name)+1:], data) + + hostRender(unsafe.Pointer(&payload[0]), uint32(len(payload))) +} diff --git a/widget.go b/widget.go new file mode 100644 index 0000000..a6bbe8f --- /dev/null +++ b/widget.go @@ -0,0 +1,173 @@ +package client + +import "encoding/xml" + +// Widget is a node in a widget tree passed to Module.Content. +type Widget interface { + widget() +} + +// Text renders a single styled line of text. +type Text struct { + XMLName xml.Name `xml:"text"` + Content string `xml:",chardata"` + Color string `xml:"color,attr,omitempty"` + FontSize float32 `xml:"fontSize,attr,omitempty"` + Condensed bool `xml:"condensed,attr,omitempty"` + Light bool `xml:"light,attr,omitempty"` + Bold bool `xml:"bold,attr,omitempty"` + Italic bool `xml:"italic,attr,omitempty"` + Align string `xml:"align,attr,omitempty"` +} + +// SVG rasterizes raw SVG markup. Used for complex assets such as a floorplan. +type SVG struct { + XMLName xml.Name `xml:"svg"` + Content string `xml:",chardata"` +} + +// VStack lays out children vertically, top to bottom. +type VStack struct { + XMLName xml.Name `xml:"vstack"` + Children []Widget `xml:"-"` +} + +// HStack lays out children horizontally, left to right. +type HStack struct { + XMLName xml.Name `xml:"hstack"` + Children []Widget `xml:"-"` +} + +// Spacer inserts flexible empty space inside a stack. +type Spacer struct { + XMLName xml.Name `xml:"spacer"` +} + +// Canvas renders draw operations within a fixed-size logical viewport scaled to +// fit the allocated space. +type Canvas struct { + XMLName xml.Name `xml:"canvas"` + Width float32 `xml:"-"` + Height float32 `xml:"-"` + Ops []DrawOp `xml:"-"` +} + +func (*Text) widget() {} +func (*SVG) widget() {} +func (*VStack) widget() {} +func (*HStack) widget() {} +func (*Spacer) widget() {} +func (*Canvas) widget() {} + +// TextOption configures a Text widget. +type TextOption func(*Text) + +// WithColor sets the text colour as an HTML hex string, e.g. "#ffffff". +func WithColor(c string) TextOption { return func(t *Text) { t.Color = c } } + +// WithFontSize sets the text size in sp. +func WithFontSize(s float32) TextOption { return func(t *Text) { t.FontSize = s } } + +// WithCondensed selects the Roboto Condensed typeface variant. +func WithCondensed() TextOption { return func(t *Text) { t.Condensed = true } } + +// WithLight selects the light (300) weight variant. +func WithLight() TextOption { return func(t *Text) { t.Light = true } } + +// WithBold selects the bold (700) weight variant. +func WithBold() TextOption { return func(t *Text) { t.Bold = true } } + +// WithItalic selects italic style. +func WithItalic() TextOption { return func(t *Text) { t.Italic = true } } + +// WithAlign sets the text alignment: "left", "center", or "right". +func WithAlign(a string) TextOption { return func(t *Text) { t.Align = a } } + +// NewText returns a Text widget with the given content and optional style options. +func NewText(content string, opts ...TextOption) *Text { + t := &Text{Content: content} + for _, o := range opts { + o(t) + } + return t +} + +// NewSVG returns an SVG widget containing the given SVG markup. +func NewSVG(content string) *SVG { + return &SVG{Content: content} +} + +// NewVStack returns a VStack widget that lays out children top to bottom. +func NewVStack(children ...Widget) *VStack { + return &VStack{Children: children} +} + +// NewHStack returns an HStack widget that lays out children left to right. +func NewHStack(children ...Widget) *HStack { + return &HStack{Children: children} +} + +// NewSpacer returns a Spacer widget that expands to fill available space. +func NewSpacer() *Spacer { + return &Spacer{} +} + +// NewCanvas returns a Canvas widget with the given logical size and ordered draw list. +func NewCanvas(width, height float32, ops ...DrawOp) *Canvas { + return &Canvas{Width: width, Height: height, Ops: ops} +} + +// MarshalXML implements xml.Marshaler. +func (v *VStack) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Name = xml.Name{Local: "vstack"} + if err := e.EncodeToken(start); err != nil { + return err + } + for _, child := range v.Children { + if child == nil { + continue + } + if err := e.Encode(child); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + +// MarshalXML implements xml.Marshaler. +func (h *HStack) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Name = xml.Name{Local: "hstack"} + if err := e.EncodeToken(start); err != nil { + return err + } + for _, child := range h.Children { + if child == nil { + continue + } + if err := e.Encode(child); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + +// MarshalXML implements xml.Marshaler. +func (c *Canvas) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Name = xml.Name{Local: "canvas"} + start.Attr = []xml.Attr{ + {Name: xml.Name{Local: "width"}, Value: formatFloat32(c.Width)}, + {Name: xml.Name{Local: "height"}, Value: formatFloat32(c.Height)}, + } + if err := e.EncodeToken(start); err != nil { + return err + } + for _, op := range c.Ops { + if op == nil { + continue + } + if err := e.Encode(op); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} From 74fa2334d8cedf0f2674f2ec4656b7449ef89eec Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Fri, 22 May 2026 06:13:30 +0200 Subject: [PATCH 2/8] feat: refactor --- go.mod | 8 ++++ go.sum | 10 +++++ logger_wasip1.go | 8 +--- widget.go | 68 +++++++++++++++++++++++++++-- widget_test.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 widget_test.go diff --git a/go.mod b/go.mod index 89215f3..651dd77 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/glasslabs/client-go go 1.26 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index e69de29..c4c1710 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logger_wasip1.go b/logger_wasip1.go index 91b5cb3..47a2539 100644 --- a/logger_wasip1.go +++ b/logger_wasip1.go @@ -9,13 +9,11 @@ import ( ) // Logger writes structured log messages to the host. -type Logger struct { - name string -} +type Logger struct{} // NewLogger returns a new Logger. func NewLogger() *Logger { - return &Logger{name: os.Getenv("MODULE_NAME")} + return &Logger{} } // Debug writes a debug log message. @@ -42,8 +40,6 @@ func (l *Logger) format(level, msg string, kvs []string) string { var b strings.Builder b.WriteString("level=") b.WriteString(level) - b.WriteString(" module=") - b.WriteString(l.name) b.WriteString(" msg=") b.WriteString(fmt.Sprintf("%q", msg)) for i := 0; i+1 < len(kvs); i += 2 { diff --git a/widget.go b/widget.go index a6bbe8f..65c62a5 100644 --- a/widget.go +++ b/widget.go @@ -20,6 +20,17 @@ type Text struct { Align string `xml:"align,attr,omitempty"` } +func (t *Text) Equals(o *Text) bool { + return t.Content == o.Content && + t.Color == o.Color && + t.FontSize == o.FontSize && + t.Condensed == o.Condensed && + t.Light == o.Light && + t.Bold == o.Bold && + t.Italic == o.Italic && + t.Align == o.Align +} + // SVG rasterizes raw SVG markup. Used for complex assets such as a floorplan. type SVG struct { XMLName xml.Name `xml:"svg"` @@ -41,6 +52,7 @@ type HStack struct { // Spacer inserts flexible empty space inside a stack. type Spacer struct { XMLName xml.Name `xml:"spacer"` + Min float32 `xml:"min,attr,omitempty"` } // Canvas renders draw operations within a fixed-size logical viewport scaled to @@ -52,6 +64,46 @@ type Canvas struct { Ops []DrawOp `xml:"-"` } +func (c *Canvas) Equals(o *Canvas) bool { + if c.Width != o.Width || c.Height != o.Height || len(c.Ops) != len(o.Ops) { + return false + } + for i, aOp := range c.Ops { + if !drawOpEqual(aOp, o.Ops[i]) { + return false + } + } + return true +} + +func drawOpEqual(a, b DrawOp) bool { + switch av := a.(type) { + case *Arc: + bv, ok := b.(*Arc) + return ok && *av == *bv + case *Rect: + bv, ok := b.(*Rect) + return ok && *av == *bv + case *Path: + bv, ok := b.(*Path) + return ok && *av == *bv + case *Label: + bv, ok := b.(*Label) + if !ok || av.X != bv.X || av.Y != bv.Y || av.Align != bv.Align || len(av.Runs) != len(bv.Runs) { + return false + } + for i, ar := range av.Runs { + br := bv.Runs[i] + if ar == nil || br == nil || *ar != *br { + return false + } + } + return true + default: + return false + } +} + func (*Text) widget() {} func (*SVG) widget() {} func (*VStack) widget() {} @@ -107,9 +159,19 @@ func NewHStack(children ...Widget) *HStack { return &HStack{Children: children} } -// NewSpacer returns a Spacer widget that expands to fill available space. -func NewSpacer() *Spacer { - return &Spacer{} +// SpacerOption configures a Spacer widget. +type SpacerOption func(*Spacer) + +// WithMinSize sets the minimum size in dp that the spacer occupies along its primary axis. +func WithMinSize(min float32) SpacerOption { return func(s *Spacer) { s.Min = min } } + +// NewSpacer returns a Spacer that expands to fill available space with a default minimum size of 8 dp. +func NewSpacer(opts ...SpacerOption) *Spacer { + s := &Spacer{Min: 8} + for _, o := range opts { + o(s) + } + return s } // NewCanvas returns a Canvas widget with the given logical size and ordered draw list. diff --git a/widget_test.go b/widget_test.go new file mode 100644 index 0000000..bf70259 --- /dev/null +++ b/widget_test.go @@ -0,0 +1,109 @@ +package client_test + +import ( + "encoding/xml" + "testing" + + "github.com/glasslabs/client-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWidget_DecodeRoundtripsText(t *testing.T) { + t.Parallel() + + original := client.NewText("15:04", + client.WithColor("#ffffff"), + client.WithFontSize(72), + client.WithCondensed(), + client.WithLight(), + client.WithAlign("center"), + ) + data, err := xml.Marshal(original) + require.NoError(t, err) + + got, err := client.DecodeWidget(data) + require.NoError(t, err) + + text, ok := got.(*client.Text) + require.True(t, ok) + assert.Equal(t, "15:04", text.Content) + assert.Equal(t, "#ffffff", text.Color) + assert.Equal(t, float32(72), text.FontSize) + assert.True(t, text.Condensed) + assert.True(t, text.Light) + assert.Equal(t, "center", text.Align) +} + +func TestWidget_DecodeRoundtripsVStack(t *testing.T) { + t.Parallel() + + original := client.NewVStack( + client.NewText("15:04", client.WithFontSize(24)), + client.NewText("Friday 15 May", client.WithFontSize(24)), + ) + data, err := xml.Marshal(original) + require.NoError(t, err) + + got, err := client.DecodeWidget(data) + require.NoError(t, err) + + vstack, ok := got.(*client.VStack) + require.True(t, ok) + require.Len(t, vstack.Children, 2) + text, ok := vstack.Children[0].(*client.Text) + require.True(t, ok) + assert.Equal(t, "15:04", text.Content) +} + +func TestWidget_DecodeRoundtripsSVG(t *testing.T) { + t.Parallel() + + svgContent := `` + original := client.NewSVG(svgContent) + data, err := xml.Marshal(original) + require.NoError(t, err) + + got, err := client.DecodeWidget(data) + require.NoError(t, err) + + svg, ok := got.(*client.SVG) + require.True(t, ok) + assert.Equal(t, svgContent, svg.Content) +} + +func TestWidget_DecodeRoundtripsCanvas(t *testing.T) { + t.Parallel() + + original := client.NewCanvas(300, 300, + client.NewArc(150, 150, 125, 123, 293.9, 45, "#282828"), + client.NewRect(105, 240, 90, 28, client.WithStroke("#646464", 1), client.WithCornerRadius(2)), + client.NewLabel(150, 150, "middle", + client.NewRun("3", client.WithRunFontSize(70), client.WithRunColor("#ffffff")), + ), + ) + data, err := xml.Marshal(original) + require.NoError(t, err) + + got, err := client.DecodeWidget(data) + require.NoError(t, err) + + canvas, ok := got.(*client.Canvas) + require.True(t, ok) + assert.Equal(t, float32(300), canvas.Width) + assert.Equal(t, float32(300), canvas.Height) + require.Len(t, canvas.Ops, 3) + + arc, ok := canvas.Ops[0].(*client.Arc) + require.True(t, ok) + assert.Equal(t, float32(150), arc.Cx) + assert.Equal(t, float32(125), arc.Radius) + assert.Equal(t, "#282828", arc.Color) +} + +func TestWidget_DecodeReturnsErrorForUnknownKind(t *testing.T) { + t.Parallel() + + _, err := client.DecodeWidget([]byte(``)) + assert.Error(t, err) +} From 9f23f4e230afb2387006e68ef194b034343a65b3 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Fri, 22 May 2026 21:26:38 +0200 Subject: [PATCH 3/8] feat: new types --- codec.go | 74 ++++++++++++++++++++++++++++++++++ widget.go | 107 +++++++++++++++++++++++++++++++++++++++++++++++++ widget_test.go | 38 ++++++++++++++++++ 3 files changed, 219 insertions(+) diff --git a/codec.go b/codec.go index d513717..d6876c7 100644 --- a/codec.go +++ b/codec.go @@ -16,6 +16,7 @@ var widgetByTag = map[string]func() Widget{ "hstack": func() Widget { return &HStack{} }, "spacer": func() Widget { return &Spacer{} }, "canvas": func() Widget { return &Canvas{} }, + "table": func() Widget { return &Table{} }, } // drawOpByTag maps XML element names to factory functions for DrawOp implementations. @@ -66,6 +67,79 @@ func (h *HStack) UnmarshalXML(d *xml.Decoder, _ xml.StartElement) error { }) } +// UnmarshalXML decodes the Table element and its Row children. +func (t *Table) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for _, attr := range start.Attr { + if attr.Name.Local == "rowSpacing" { + f, _ := strconv.ParseFloat(attr.Value, 32) + t.RowSpacing = float32(f) + } + } + for { + tok, err := d.Token() + if err != nil { + return err + } + switch el := tok.(type) { + case xml.StartElement: + if el.Name.Local != "row" { + if err = d.Skip(); err != nil { + return err + } + continue + } + row := &Row{} + if err = d.DecodeElement(row, &el); err != nil { + return err + } + t.Rows = append(t.Rows, row) + case xml.EndElement: + return nil + } + } +} + +// UnmarshalXML decodes the Row element and its Column children. +func (r *Row) UnmarshalXML(d *xml.Decoder, _ xml.StartElement) error { + for { + tok, err := d.Token() + if err != nil { + return err + } + switch el := tok.(type) { + case xml.StartElement: + if el.Name.Local != "column" { + if err = d.Skip(); err != nil { + return err + } + continue + } + col := &Column{} + if err = d.DecodeElement(col, &el); err != nil { + return err + } + r.Columns = append(r.Columns, col) + case xml.EndElement: + return nil + } + } +} + +// UnmarshalXML decodes the Column element, its minWidth attribute, and its single Widget child. +func (c *Column) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + for _, attr := range start.Attr { + if attr.Name.Local == "minWidth" { + f, _ := strconv.ParseFloat(attr.Value, 32) + c.MinWidth = float32(f) + } + } + return decodeWidgetChildren(d, func(child Widget) { + if c.Child == nil { + c.Child = child + } + }) +} + // UnmarshalXML decodes the Canvas element, its width/height attributes, and DrawOp children. func (c *Canvas) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { for _, attr := range start.Attr { diff --git a/widget.go b/widget.go index 65c62a5..45dc673 100644 --- a/widget.go +++ b/widget.go @@ -49,6 +49,27 @@ type HStack struct { Children []Widget `xml:"-"` } +// Column is a cell within a Row, holding a single child widget. +type Column struct { + XMLName xml.Name `xml:"column"` + MinWidth float32 `xml:"minWidth,attr,omitempty"` + Child Widget `xml:"-"` +} + +// Row is a horizontal sequence of Column cells within a Table. +type Row struct { + XMLName xml.Name `xml:"row"` + Columns []*Column `xml:"-"` +} + +// Table lays out Row children vertically, automatically sizing each column +// to the widest natural width across all rows. +type Table struct { + XMLName xml.Name `xml:"table"` + RowSpacing float32 `xml:"rowSpacing,attr,omitempty"` + Rows []*Row `xml:"-"` +} + // Spacer inserts flexible empty space inside a stack. type Spacer struct { XMLName xml.Name `xml:"spacer"` @@ -108,6 +129,7 @@ func (*Text) widget() {} func (*SVG) widget() {} func (*VStack) widget() {} func (*HStack) widget() {} +func (*Table) widget() {} func (*Spacer) widget() {} func (*Canvas) widget() {} @@ -174,11 +196,96 @@ func NewSpacer(opts ...SpacerOption) *Spacer { return s } +// NewColumn returns a Column containing child with an optional minimum width in dp. +func NewColumn(child Widget, minWidth float32) *Column { + return &Column{Child: child, MinWidth: minWidth} +} + +// NewRow returns a Row containing the given Column cells. +func NewRow(cols ...*Column) *Row { + return &Row{Columns: cols} +} + +// TableOption configures a Table widget. +type TableOption func(*Table) + +// WithRowSpacing sets the vertical gap in dp between rows. +func WithRowSpacing(dp float32) TableOption { return func(t *Table) { t.RowSpacing = dp } } + +// NewTable returns a Table containing the given Row children. +func NewTable(rows []*Row, opts ...TableOption) *Table { + t := &Table{Rows: rows} + for _, o := range opts { + o(t) + } + return t +} + // NewCanvas returns a Canvas widget with the given logical size and ordered draw list. func NewCanvas(width, height float32, ops ...DrawOp) *Canvas { return &Canvas{Width: width, Height: height, Ops: ops} } +// MarshalXML implements xml.Marshaler. +func (t *Table) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Name = xml.Name{Local: "table"} + if t.RowSpacing > 0 { + start.Attr = append(start.Attr, xml.Attr{ + Name: xml.Name{Local: "rowSpacing"}, + Value: formatFloat32(t.RowSpacing), + }) + } + if err := e.EncodeToken(start); err != nil { + return err + } + for _, row := range t.Rows { + if row == nil { + continue + } + if err := e.Encode(row); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + +// MarshalXML implements xml.Marshaler. +func (r *Row) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Name = xml.Name{Local: "row"} + if err := e.EncodeToken(start); err != nil { + return err + } + for _, col := range r.Columns { + if col == nil { + continue + } + if err := e.Encode(col); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + +// MarshalXML implements xml.Marshaler. +func (c *Column) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + start.Name = xml.Name{Local: "column"} + if c.MinWidth > 0 { + start.Attr = append(start.Attr, xml.Attr{ + Name: xml.Name{Local: "minWidth"}, + Value: formatFloat32(c.MinWidth), + }) + } + if err := e.EncodeToken(start); err != nil { + return err + } + if c.Child != nil { + if err := e.Encode(c.Child); err != nil { + return err + } + } + return e.EncodeToken(start.End()) +} + // MarshalXML implements xml.Marshaler. func (v *VStack) MarshalXML(e *xml.Encoder, start xml.StartElement) error { start.Name = xml.Name{Local: "vstack"} diff --git a/widget_test.go b/widget_test.go index bf70259..8aba088 100644 --- a/widget_test.go +++ b/widget_test.go @@ -101,6 +101,44 @@ func TestWidget_DecodeRoundtripsCanvas(t *testing.T) { assert.Equal(t, "#282828", arc.Color) } +func TestWidget_DecodeRoundtripsTable(t *testing.T) { + t.Parallel() + + original := client.NewTable( + []*client.Row{ + client.NewRow( + client.NewColumn(client.NewText("Mon", client.WithFontSize(14)), 60), + client.NewColumn(client.NewText("2 Jan", client.WithFontSize(20)), 0), + ), + client.NewRow( + client.NewColumn(client.NewText("Tue", client.WithFontSize(14)), 60), + client.NewColumn(client.NewText("3 Jan", client.WithFontSize(20)), 0), + ), + }, + client.WithRowSpacing(8), + ) + data, err := xml.Marshal(original) + require.NoError(t, err) + + got, err := client.DecodeWidget(data) + require.NoError(t, err) + + table, ok := got.(*client.Table) + require.True(t, ok) + assert.Equal(t, float32(8), table.RowSpacing) + require.Len(t, table.Rows, 2) + + row := table.Rows[0] + require.Len(t, row.Columns, 2) + + col := row.Columns[0] + assert.Equal(t, float32(60), col.MinWidth) + text, ok := col.Child.(*client.Text) + require.True(t, ok) + assert.Equal(t, "Mon", text.Content) + assert.Equal(t, float32(14), text.FontSize) +} + func TestWidget_DecodeReturnsErrorForUnknownKind(t *testing.T) { t.Parallel() From 4d3df14570a001d4bb03eeb15b8d4e89e0ccb6f8 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Sat, 23 May 2026 16:44:54 +0200 Subject: [PATCH 4/8] fix: workflows --- .github/PULL_REQUEST_TEMPLATE.md | 11 +++++++ .github/workflows/test.yml | 52 +++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3bd710a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Goal of this PR + + + + + +## How did I test it? + + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1ce4eb..05dc4a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,35 +6,53 @@ on: name: run tests jobs: - test: - + lint: runs-on: ubuntu-latest env: - GOOS: js + GOOS: wasip1 GOARCH: wasm - GO_VERSION: "1.21" - GOLANGCI_LINT_VERSION: v1.55.0 + GOLANGCI_LINT_VERSION: v2.11.4 steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Install Go - if: success() + id: install-go uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: 'go.mod' + + - name: Download dependencies + run: go mod download + if: steps.install-go.outputs.cache-hit != 'true' + + - name: Run linter + uses: golangci/golangci-lint-action@v9 + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + test: + runs-on: ubuntu-latest + + steps: - name: Checkout code uses: actions/checkout@v6 - - name: Cache Go modules - uses: actions/cache@v4 + - name: Install Go + id: install-go + uses: actions/setup-go@v6 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + go-version-file: 'go.mod' - - name: Run linter - uses: golangci/golangci-lint-action@v6 + - name: Download dependencies + run: go mod download + if: steps.install-go.outputs.cache-hit != 'true' + + - name: Setup gotestsum + uses: gertd/action-gotestsum@v3.0.0 with: - version: ${{ env.GOLANGCI_LINT_VERSION }} - skip-pkg-cache: true + gotestsum_version: v1.13.0 + + - name: Run Tests + run: gotestsum --format pkgname -- -cover -race ./... \ No newline at end of file From eeab489055442898e66902af89aec60eaf2d6003 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Sat, 23 May 2026 16:49:25 +0200 Subject: [PATCH 5/8] fix: linter --- .github/workflows/test.yml | 6 ++++-- widget.go | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 05dc4a1..0fec160 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,13 +4,15 @@ on: - main pull_request: +env: + GOOS: wasip1 + GOARCH: wasm + name: run tests jobs: lint: runs-on: ubuntu-latest env: - GOOS: wasip1 - GOARCH: wasm GOLANGCI_LINT_VERSION: v2.11.4 steps: diff --git a/widget.go b/widget.go index 45dc673..3a5ab39 100644 --- a/widget.go +++ b/widget.go @@ -20,6 +20,8 @@ type Text struct { Align string `xml:"align,attr,omitempty"` } +// Equals checks the logical equality of two Text widgets, ignoring differences that don't +// affect rendering such as the order of style options. func (t *Text) Equals(o *Text) bool { return t.Content == o.Content && t.Color == o.Color && @@ -85,6 +87,8 @@ type Canvas struct { Ops []DrawOp `xml:"-"` } +// Equals checks the logical equality of two Canvas widgets, ignoring differences that don't +// affect rendering such as the order of draw operations with identical parameters. func (c *Canvas) Equals(o *Canvas) bool { if c.Width != o.Width || c.Height != o.Height || len(c.Ops) != len(o.Ops) { return false @@ -185,7 +189,7 @@ func NewHStack(children ...Widget) *HStack { type SpacerOption func(*Spacer) // WithMinSize sets the minimum size in dp that the spacer occupies along its primary axis. -func WithMinSize(min float32) SpacerOption { return func(s *Spacer) { s.Min = min } } +func WithMinSize(minSize float32) SpacerOption { return func(s *Spacer) { s.Min = minSize } } // NewSpacer returns a Spacer that expands to fill available space with a default minimum size of 8 dp. func NewSpacer(opts ...SpacerOption) *Spacer { From fafa7d244913e74cea067c8288bcee0ff94d7b1d Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Sat, 23 May 2026 16:55:07 +0200 Subject: [PATCH 6/8] fix: ci --- .github/workflows/test.yml | 2 +- .golangci.yml | 7 +++++-- Makefile | 2 +- http_wasip1.go | 1 - logger_wasip1.go | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fec160..551ef3e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,4 +57,4 @@ jobs: gotestsum_version: v1.13.0 - name: Run Tests - run: gotestsum --format pkgname -- -cover -race ./... \ No newline at end of file + run: gotestsum --format pkgname -- -cover ./... \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 6928147..f3fe5d7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -43,9 +43,12 @@ linters: max-complexity: 20 gosec: excludes: - - G302 - - G306 + - G103 + - G115 + - G304 lll: line-length: 160 + perfsprint: + error-format: false exclusions: generated: lax diff --git a/Makefile b/Makefile index 0b5fd35..6d9a9eb 100644 --- a/Makefile +++ b/Makefile @@ -25,5 +25,5 @@ test: # Lint the project lint: - @golangci-lint run ./... + @GOOS=wasip1 GOARC=wasm golangci-lint run ./... .PHONY: lint diff --git a/http_wasip1.go b/http_wasip1.go index 9d4ccd4..3303d76 100644 --- a/http_wasip1.go +++ b/http_wasip1.go @@ -147,4 +147,3 @@ func (b *streamBody) Close() error { } return nil } - diff --git a/logger_wasip1.go b/logger_wasip1.go index 47a2539..69e2e30 100644 --- a/logger_wasip1.go +++ b/logger_wasip1.go @@ -41,14 +41,14 @@ func (l *Logger) format(level, msg string, kvs []string) string { b.WriteString("level=") b.WriteString(level) b.WriteString(" msg=") - b.WriteString(fmt.Sprintf("%q", msg)) + _, _ = fmt.Fprintf(&b, "%q", msg) for i := 0; i+1 < len(kvs); i += 2 { b.WriteByte(' ') b.WriteString(kvs[i]) b.WriteByte('=') val := kvs[i+1] if strings.ContainsAny(val, " \t\r\n\"") { - b.WriteString(fmt.Sprintf("%q", val)) + _, _ = fmt.Fprintf(&b, "%q", val) } else { b.WriteString(val) } From ef32af90111fbfca81fe096852e51914d42af157 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Sat, 23 May 2026 16:56:37 +0200 Subject: [PATCH 7/8] fix: test --- .github/workflows/test.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 551ef3e..cee58f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,10 +51,5 @@ jobs: run: go mod download if: steps.install-go.outputs.cache-hit != 'true' - - name: Setup gotestsum - uses: gertd/action-gotestsum@v3.0.0 - with: - gotestsum_version: v1.13.0 - - name: Run Tests - run: gotestsum --format pkgname -- -cover ./... \ No newline at end of file + run: go test -cover ./... \ No newline at end of file From 93c4b4ec7be6577b1da7812ddae2958d714edd1e Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Sat, 23 May 2026 17:00:03 +0200 Subject: [PATCH 8/8] fix: tests --- .github/workflows/test.yml | 6 ++---- Makefile | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cee58f1..6b4d05e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,15 +4,13 @@ on: - main pull_request: -env: - GOOS: wasip1 - GOARCH: wasm - name: run tests jobs: lint: runs-on: ubuntu-latest env: + GOOS: wasip1 + GOARCH: wasm GOLANGCI_LINT_VERSION: v2.11.4 steps: diff --git a/Makefile b/Makefile index 6d9a9eb..f0fc672 100644 --- a/Makefile +++ b/Makefile @@ -25,5 +25,5 @@ test: # Lint the project lint: - @GOOS=wasip1 GOARC=wasm golangci-lint run ./... + @GOOS=wasip1 GOARCH=wasm golangci-lint run ./... .PHONY: lint