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..6b4d05e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -6,35 +6,48 @@ 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
- with:
- version: ${{ env.GOLANGCI_LINT_VERSION }}
- skip-pkg-cache: true
+ - name: Download dependencies
+ run: go mod download
+ if: steps.install-go.outputs.cache-hit != 'true'
+
+ - name: Run Tests
+ run: go test -cover ./...
\ No newline at end of file
diff --git a/.golangci.yml b/.golangci.yml
index 36ca989..f3fe5d7 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,48 +1,54 @@
+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:
+ - G103
+ - G115
+ - G304
+ lll:
+ line-length: 160
+ perfsprint:
+ error-format: false
+ exclusions:
+ generated: lax
diff --git a/Makefile b/Makefile
index 0b5fd35..f0fc672 100644
--- a/Makefile
+++ b/Makefile
@@ -25,5 +25,5 @@ test:
# Lint the project
lint:
- @golangci-lint run ./...
+ @GOOS=wasip1 GOARCH=wasm golangci-lint run ./...
.PHONY: lint
diff --git a/README.md b/README.md
index a496b32..a4d5c53 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,185 @@
[](https://github.com/glasslabs/client-go/releases)
[](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..d6876c7
--- /dev/null
+++ b/codec.go
@@ -0,0 +1,219 @@
+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{} },
+ "table": func() Widget { return &Table{} },
+}
+
+// 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 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 {
+ 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..651dd77 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,11 @@
module github.com/glasslabs/client-go
-go 1.21.3
+go 1.26
+
+require github.com/stretchr/testify v1.11.1
require (
- gopkg.in/yaml.v3 v3.0.1
- honnef.co/go/js/dom/v2 v2.0.0-20231112215516-51f43a291193
+ 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 653770e..c4c1710 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +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=
-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..3303d76
--- /dev/null
+++ b/http_wasip1.go
@@ -0,0 +1,149 @@
+//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..69e2e30
--- /dev/null
+++ b/logger_wasip1.go
@@ -0,0 +1,57 @@
+//go:build wasip1
+
+package client
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// Logger writes structured log messages to the host.
+type Logger struct{}
+
+// NewLogger returns a new Logger.
+func NewLogger() *Logger {
+ return &Logger{}
+}
+
+// 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(" 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\"") {
+ _, _ = fmt.Fprintf(&b, "%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..3a5ab39
--- /dev/null
+++ b/widget.go
@@ -0,0 +1,346 @@
+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"`
+}
+
+// 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 &&
+ 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"`
+ 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:"-"`
+}
+
+// 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"`
+ Min float32 `xml:"min,attr,omitempty"`
+}
+
+// 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:"-"`
+}
+
+// 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
+ }
+ 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() {}
+func (*HStack) widget() {}
+func (*Table) 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}
+}
+
+// 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(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 {
+ s := &Spacer{Min: 8}
+ for _, o := range opts {
+ o(s)
+ }
+ 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"}
+ 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())
+}
diff --git a/widget_test.go b/widget_test.go
new file mode 100644
index 0000000..8aba088
--- /dev/null
+++ b/widget_test.go
@@ -0,0 +1,147 @@
+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_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()
+
+ _, err := client.DecodeWidget([]byte(``))
+ assert.Error(t, err)
+}