Help us shape the future of Go! Take the 2026 Go Developer Survey

skin

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: BSD-3-Clause Imports: 12 Imported by: 0

README

go-widgets/skin

CI release pkg.go.dev coverage license

A declarative theme + layout + interaction-state engine for the go-widgets/toolkit — the fleet's Edje analogue, named generically as skin.

You describe a widget's parts, their per-state visuals, and the signal → state transitions in a data file (.skin.json). Re-skinning — or building an entirely new control — then needs no Go recompile: the running program loads different bytes.

Pure Go, standard library only (encoding/json), CGO-free, BSD-3-Clause, 100% statement coverage, validated on all six 64-bit Go targets.

Why

The toolkit's widgets are hand-coded in Go: a Button's parts, its hover / pressed / disabled faces and its click feedback all live in button.go. That is fast and precise, but every visual tweak is a code change and a rebuild, and every new control is a new Go type.

skin moves that description into data. One JSON document can express a button, a switch, a chip — their parts, their states, and how signals move them between states — and the same engine renders and animates all of them. The three layers compose cleanly:

layer responsibility
the toolkit's VBox/HBox/Grid/Frame inter-widget placement
skin intra-widget parts + interaction-state
toolkit/anim driving the transitions

A skin.Object is a toolkit.Widget, so it drops straight into the existing layouts next to hand-coded widgets.

Quickstart

theme, err := skin.Load(jsonBytes)          // parse + validate every collection
if err != nil { log.Fatal(err) }

obj, _ := theme.New("button")               // one live instance
obj.Bind(skin.NewMVVMContext().Set("$.label", labelObs))  // data binding
obj.SetBounds(toolkit.Rect{W: 96, H: 28})

// In your frame loop:
obj.OnEvent(ev)                              // pointer events → signals → states
busy := obj.Tick(time.Now())                // advance transitions (anim driver)
obj.Draw(painter, theme.Palette())           // paint; satisfies toolkit.Widget

// React to a program's `emit`:
select {
case sig := <-obj.SignalsOut(): cmd.Execute() // e.g. wire to an mvvm.Command
default:
}

Format

The canonical surface is JSON, decoded with the standard library — zero third-party dependencies (a sovereignty requirement of the fleet). The parse pipeline is deliberately layered:

raw bytes ──encoding/json──▶ document (Go struct model) ──validate──▶ *skin.Theme

Only the first arrow is JSON-specific. JSON now; HCL later: a friendlier front-end (an HCL surface, say) can be added later by emitting the same document model and reusing the whole validator + runtime unchanged. We deliberately do not depend on hashicorp/hcl — we don't own it, and the fleet has been removing third-party dependencies, not adding them; an owned HCL surface can be added when it's wanted.

Schema (.skin.json)
{
  "collections": {
    "button": {                       // a named widget/layout template
      "min": { "w": 96, "h": 28 },
      "parts": [
        {
          "name": "bg",
          "type": "rect",             // rect | text | image
          "rel1": { "to": "", "relative": [0,0], "offset": [0,0] },
          "rel2": { "to": "", "relative": [1,1], "offset": [0,0] },
          "states": {                 // default | hover | pressed | disabled | custom…
            "default":  { "color": "@surface",     "border": "@border",   "radius": 6 },
            "hover":    { "color": "@surface_alt", "border": "@border",   "radius": 6 },
            "pressed":  { "color": "@accent",      "border": "@border",   "radius": 6 },
            "disabled": { "color": "@muted_face",  "border": "@muted_ink","radius": 6 }
          }
        },
        {
          "name": "label", "type": "text", "text_from": "$.label",
          "align": [0.5, 0.5],
          "states": { "default": { "ink": "@on_surface" }, "pressed": { "ink": "@background" } }
        }
      ],
      "programs": [
        { "on": "mouse,in",  "target": ["bg","label"], "to": "hover",   "in": 0.12, "ease": "ease_out_cubic" },
        { "on": "mouse,down","target": ["bg","label"], "to": "pressed", "in": 0.06 },
        { "on": "mouse,click", "emit": "clicked" }
      ]
    }
  }
}
  • part — name, type (rect/text/image), default geometry (rel1/rel2/align), an optional text_from binding path (or static text) or image drawable name, and a map of states.
  • rel1/rel2 — geometry endpoints. Each is a fractional position (relative, [rx, ry] in 0..1) across a reference box, plus a pixel offset and a font-relative offset_em ([ex, ey], in em = glyph-height units). The endpoint resolves to base.origin + relative*base.size + offset + round(offset_em*glyphHeight). The reference is the object itself (to: "") or an earlier sibling part (to: "<name>"). rel1 is the top-left, rel2 the far corner. Pixel-only authoring (no offset_em) reproduces the historical behaviour byte-for-byte; offset_em lets a text-driven part scale with the font instead of assuming the default 5×7 (a card's header height is 1em + 12px, so it re-lays out under any SetFont). A state may override the endpoints, so a part can move or resize between states.
  • aspect / aspect_mode — an optional per-part width:height ratio held within the rect rel1/rel2 carve out (the Edje aspect idea). aspect_mode is one of none (default), both (fit the largest rect of ratio pref that contains inside — a square switch knob at any track size), horizontal (height authoritative, width = height*pref), vertical (width authoritative, height = width/pref) or neither (keep the rect's own ratio while it lies in [min, max], otherwise shrink to the nearest bound). aspect carries pref (target ratio for both/horizontal/vertical) or min+max (range for neither). The ratio-held rect seats inside its allotted box by the part's (or state's) align, so align [0,0.5]/[1,0.5] pin a contained square to the left/right edge — how the switch knob slides without any pixel geometry.
  • state — visual props: color (fill), ink (text/glyph), border, border_width, radius (a number or "pill"), visible, and optional per-state geometry (rel1/rel2/align). Colour values are @tokens (@surface, @accent, @muted_ink, @accent_fg, @extra:<gtk-color>), #RRGGBB(AA) hex, or [r,g,b(,a)] arrays.
  • program — on (a signal), target (part names), to (their new state), in (transition seconds, driven by toolkit/anim), ease (a named toolkit easing) and an optional outward emit.

@tokens resolve against a toolkit.Theme — a superset of LoadGTKTheme: every canonical palette field plus every GTK @define-color entry (via @extra:<name>).

Composition with the toolkit

Because Object implements the full toolkit.Widget interface (Bounds/SetBounds/Draw/HitTest/OnEvent), a skinned control is placed by the existing Go layouts exactly like any hand-coded widget:

row := toolkit.NewHBox()
row.Add(skinnedButton)   // a *skin.Object
row.Add(toolkit.NewLabel("hand-coded neighbour"))

skin owns what is inside a widget (its parts and their interaction states); the toolkit's layouts own where widgets sit.

Parity

The engine is proven against reality: the toolkit's hand-coded button, check, switch, chip and card are each re-expressed as a .skin.json collection (testdata/) and asserted pixel-for-pixel identical to the Go originals across their default/hover/pressed/disabled states (parity_test.go). A skinned Draw measures within ~1.5× of the hand-coded Draw (bench_test.go; observed ≈1.0×).

License

BSD-3-Clause — see LICENSE. Copyright (c) 2026 the go-widgets/skin authors.

Documentation

Overview

Package skin is a declarative theme + layout + interaction-state engine for the go-widgets toolkit — the "Edje analogue" of the fleet, named generically as skin.

An app describes a widget's PARTS (rectangles, text, images), their per-STATE visuals (fill/ink/border/radius/geometry) and the SIGNAL→state PROGRAMS that wire interaction to appearance in a DATA FILE (`.skin.json`). Re-skinning — or building an entirely new control — then needs no Go recompile: the running program loads different bytes.

The three layers of the toolkit compose cleanly:

Format

The canonical surface is JSON via the standard library's encoding/json, with ZERO third-party dependencies (a sovereignty requirement of the fleet). The parse pipeline is deliberately layered so a friendlier front-end (an HCL surface, say) can be bolted on LATER without touching the runtime:

raw bytes ──json──▶ document (documented Go struct model) ──validate──▶ *Theme

See Load. Only the first arrow is JSON-specific; everything downstream of the document model is format-agnostic. We do NOT depend on hashicorp/hcl (we don't own it); an HCL front-end, if ever added, would emit the same document model and reuse the whole validator + runtime unchanged.

Size- and font-relative geometry

Part geometry scales to ANY object size and font metrics. A rel1/rel2 endpoint is a FRACTIONAL position across its reference box (relative, 0..1) plus a pixel offset and a FONT-RELATIVE offset (offset_em, in em = glyph-height units resolved against the active github.com/go-widgets/toolkit font). A part may additionally hold a width:height ratio inside its allotted rect via aspect/aspect_mode (the Edje aspect idea — none/neither/horizontal/ vertical/both), seated by the part's align. So the shipped switch/check/card collections render correctly at arbitrary bounds and under any SetFont rather than assuming the default 5x7 face.

Runtime

Load parses+validates every collection into a Theme. Theme.New instantiates a live Object for one collection; the Object is a full toolkit.Widget. Drive it with Object.Signal ("mouse,in", "mouse,down", …), advance transitions from the host frame loop with Object.Tick, and paint with Object.Draw. Emitted signals leave via Object.SignalsOut so a host can wire them to an github.com/go-widgets/mvvm.Command.

Example

Example loads a one-collection document, instantiates it, drives its state machine with a signal and inspects the result — the whole pipeline in miniature.

package main

import (
	"fmt"

	"github.com/go-widgets/skin"
	"github.com/go-widgets/toolkit"
)

func main() {
	const doc = `{
	  "collections": {
	    "pill": {
	      "min": {"w": 60, "h": 20},
	      "parts": [
	        {"name": "bg", "type": "rect",
	         "rel1": {}, "rel2": {"relative": [1,1]},
	         "states": {
	           "default": {"color": "@surface", "radius": "pill"},
	           "active":  {"color": "@accent",  "radius": "pill"}
	         }}
	      ],
	      "programs": [
	        {"on": "select", "target": ["bg"], "to": "active", "in": 0, "emit": "selected"}
	      ]
	    }
	  }
	}`

	theme, err := skin.Load([]byte(doc))
	if err != nil {
		panic(err)
	}
	fmt.Println("collections:", theme.Collections())

	obj, _ := theme.New("pill")
	obj.SetBounds(toolkit.Rect{X: 0, Y: 0, W: 60, H: 20})

	before, _ := obj.State("bg")
	obj.Signal("select") // instant (in:0), also emits "selected"
	after, _ := obj.State("bg")
	fmt.Printf("bg: %s -> %s\n", before, after)
	fmt.Println("emitted:", <-obj.SignalsOut())

	r, _ := obj.PartRect("bg")
	fmt.Printf("bg rect: %dx%d\n", r.W, r.H)

}
Output:
collections: [pill]
bg: default -> active
emitted: selected
bg rect: 60x20

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Collection

type Collection struct {
	Name       string
	MinW, MinH int
	Parts      []*Part
	Programs   []*Program
	// contains filtered or unexported fields
}

Collection is one named widget/layout template: a minimum size, an ordered list of parts (drawn back-to-front) and the signal→state programs that drive them. Parts resolve their geometry in list order, so a part may only anchor to a sibling declared BEFORE it (no forward references, no cycles).

type Drawable

type Drawable func(p painter.Painter, r toolkit.Rect, ink toolkit.RGBA)

Drawable is a host-registered painter for a PartImage — the "swallow" seam for content the rect/text vocabulary can't express (a vector icon, a photo). It is handed the part's resolved rect and the part's current ink so the glyph can track state tints (pressed/disabled) exactly like a toolkit widget's host-supplied Icon callback.

type MVVMContext

type MVVMContext struct {
	// contains filtered or unexported fields
}

MVVMContext is a TextSource backed by github.com/go-widgets/mvvm observables: it maps a document's text_from paths (e.g. "$.title") to live string [mvvm.Observable]s. Because Object.Draw pulls the current value each frame, an observable's later Set shows on the next paint — the data binding the toolkit's MVVM layer is meant to provide, wired into skin's declarative text parts.

func NewMVVMContext

func NewMVVMContext() *MVVMContext

NewMVVMContext returns an empty context. Register paths with MVVMContext.Set.

func (*MVVMContext) Get

func (c *MVVMContext) Get(path string) (string, bool)

Get resolves path to its observable's current value, reporting ok=false when the path is unbound so the part falls back to its static Text.

func (*MVVMContext) Set

func (c *MVVMContext) Set(path string, o *mvvm.Observable[string]) *MVVMContext

Set binds a text_from path to an observable and returns the context so calls chain: ctx.Set("$.title", title).Set("$.subtitle", subtitle).

type Object

type Object struct {
	toolkit.Base
	// contains filtered or unexported fields
}

Object is one live instance of a collection: a full toolkit.Widget that owns its parts' current states and any in-flight transitions. Drop it into a VBox/HBox/Grid like any other widget; drive its state machine with Object.Signal and advance transitions with Object.Tick.

func (*Object) Animating

func (o *Object) Animating() bool

Animating reports whether any part currently has a transition in flight.

func (*Object) Bind

func (o *Object) Bind(src TextSource)

Bind sets the source Object resolves text_from paths against. Binding is a pull model: Object.Draw reads the current value each frame, so a later change to the source shows on the next paint with no re-bind. A nil src clears the binding (parts fall back to their static Text).

func (*Object) Draw

func (o *Object) Draw(p painter.Painter, th *toolkit.Theme)

Draw paints every part back-to-front through p using th's palette (pass Theme.Palette for the document's own palette). It satisfies toolkit.Widget.

func (*Object) OnEvent

func (o *Object) OnEvent(ev toolkit.Event)

OnEvent translates toolkit input into skin signals so an Object behaves like any interactive widget inside a container: hover in/out from EventMouseMove, press from EventClick (also raising the activation signal "mouse,click"), and release from EventMouseUp. A collection wires these to states via programs; events with no matching program are ignored.

func (*Object) PartRect

func (o *Object) PartRect(name string) (toolkit.Rect, bool)

PartRect returns a part's resolved rectangle in its current settled state, against the object's current bounds — the frame it would paint into this tick. ok is false for an unknown part name. Useful for a host that hit-tests or overlays a specific sub-part (a chip's close slot, a card's header).

func (*Object) SetImage

func (o *Object) SetImage(name string, d Drawable)

SetImage registers a Drawable under name so a PartImage referencing that name paints through it. Registering nil removes the drawable.

func (*Object) SetState

func (o *Object) SetState(name string)

SetState instantly puts every part into the named state (no animation), cancelling any transition in flight. It models app-owned state — a control the app marks "disabled", a tab it marks "selected" — that is set directly rather than derived from pointer signals. Parts that don't describe name fall back to their "default" description at draw time.

func (*Object) Signal

func (o *Object) Signal(name string)

Signal drives the state machine: every program whose `on` equals name fires, transitioning its target parts and raising its `emit` (if any) outward. Unmatched signals are a harmless no-op.

func (*Object) SignalsOut

func (o *Object) SignalsOut() <-chan string

SignalsOut returns the channel emitted signals are delivered on. Wire it to an github.com/go-widgets/mvvm.Command (or any handler) to react to a program's `emit`. The channel is buffered; drain it from the host loop.

func (*Object) State

func (o *Object) State(part string) (state string, ok bool)

State returns the settled state a part is in (its transition TARGET while one runs). ok is false for an unknown part name. Handy for tests and for a host that mirrors part state into its model.

func (*Object) Tick

func (o *Object) Tick(now time.Time) bool

Tick advances every in-flight transition to now via the toolkit anim driver and reports the driver's busy flag straight through: when it is false nothing is animating and the host may stop scheduling frames.

type Part

type Part struct {
	Name     string
	Type     PartType
	TextFrom string // mvvm-style binding path ($.title); "" = none
	Text     string // static text fallback when TextFrom is empty/unbound
	Image    string // drawable name for PartImage; "" = none
	// contains filtered or unexported fields
}

Part is one element of a collection: a primitive with default geometry, an optional text/image source, and a map of per-state descriptions. Every part MUST define a "default" state; other states are optional and fall back to "default" when the object is in a state the part doesn't describe.

type PartType

type PartType int

PartType is the kind of primitive a part paints.

const (
	// PartRect is a (optionally rounded, optionally bordered) filled rectangle.
	PartRect PartType = iota
	// PartText draws a string (from a binding or a static literal) in the
	// toolkit's active font, positioned by the part's align within its box.
	PartText
	// PartImage delegates to a host-registered drawable (see
	// [Object.SetImage]) — the "swallow"/external seam for content the
	// rect/text vocabulary can't express (a real vector icon, a photo …).
	PartImage
)

type Program

type Program struct {
	On     string
	Target []string
	To     string
	In     float64
	Ease   toolkit.Easing
	Emit   string
}

Program is one signal→state rule: when signal On is received, every part in Target transitions To the named state over In seconds shaped by Ease, and (if Emit is set) a signal Emit is raised outward. A Target may be empty (a pure emitter). In == 0 snaps instantly with no animation.

type State

type State struct {
	Name string
	// contains filtered or unexported fields
}

State is one part description: the visual it paints while the part is in this state, plus optional geometry/alignment overrides (so a part can MOVE or RESIZE between states — how a switch knob slides). Any unset visual inherits nothing; it simply isn't painted (an unset fill draws no body, an unset border draws no stroke).

type TextSource

type TextSource interface {
	Get(path string) (string, bool)
}

TextSource resolves a part's text_from path (e.g. "$.title") to a live string. Get reports ok=false when the path is unbound, so the part falls back to its static Text. See MVVMContext for an github.com/go-widgets/mvvm -backed implementation.

type Theme

type Theme struct {
	// contains filtered or unexported fields
}

Theme is a parsed+validated set of skin collections together with the palette its @-tokens resolve against. It is the output of Load and the factory for live [Object]s (see Theme.New). A Theme is immutable after Load except for Theme.SetPalette; it holds no per-instance state, so one Theme backs any number of concurrent Objects.

func Load

func Load(jsonSrc []byte) (*Theme, error)

Load parses jsonSrc and validates every collection, returning a ready Theme whose palette defaults to toolkit.DefaultLight. Unknown JSON keys are rejected (a typo'd field is an error, not a silent no-op). Every failure is a rich error naming the offending collection/part/state/program.

func (*Theme) Collections

func (t *Theme) Collections() []string

Collections returns the collection names in sorted order — handy for a host that enumerates what a loaded document offers.

func (*Theme) New

func (t *Theme) New(collection string) (*Object, error)

New instantiates the named collection as a live Object, sized to the collection's min. It errors if the collection is not in this Theme.

func (*Theme) Palette

func (t *Theme) Palette() *toolkit.Theme

Palette returns the toolkit.Theme this skin's @-tokens resolve against. It defaults to toolkit.DefaultLight; swap it (or a toolkit.LoadGTKTheme result, whose Extra map feeds @extra:<name> tokens) via Theme.SetPalette. The returned pointer is the live palette — a caller may pass it straight to Object.Draw.

func (*Theme) SetPalette

func (t *Theme) SetPalette(p *toolkit.Theme)

SetPalette replaces the palette @-tokens resolve against. Passing the result of toolkit.LoadGTKTheme makes skin a strict superset of that loader: every canonical field plus every @define-color entry (via @extra:<name>) becomes referenceable from a document. A nil p restores toolkit.DefaultLight.

type UnknownCollectionError

type UnknownCollectionError struct{ Name string }

UnknownCollectionError reports that Theme.New was asked for a collection the document doesn't define.

func (*UnknownCollectionError) Error

func (e *UnknownCollectionError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL