aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2026-03-02 17:38:55 -0500
committerSam Anthony <sam@samanthony.xyz>2026-03-02 17:38:55 -0500
commit8858a54b5ddb3a2d8a42ecb1a837c02800bc934f (patch)
tree2428c445936eb8865c0b7c8f52560e97a44082f3
parentc6cd37a6b80d4e5ad1dbf8c8968b818811b8aa54 (diff)
downloadgui-8858a54b5ddb3a2d8a42ecb1a837c02800bc934f.zip
create lay/strain package
The lay/strain package uses the Cassowary algorithm to solve systems of layout constraints.
-rw-r--r--constraint.go34
-rw-r--r--env.go4
-rw-r--r--go.mod6
-rw-r--r--go.sum12
-rw-r--r--internal/log/log.go11
-rw-r--r--internal/tag/tag.go12
-rw-r--r--lay/strain/bound.go28
-rw-r--r--lay/strain/constraint.go28
-rw-r--r--lay/strain/doc.go6
-rw-r--r--lay/strain/solve.go276
-rw-r--r--lay/strain/sym.go70
-rw-r--r--mux.go4
-rw-r--r--style/style.go22
13 files changed, 477 insertions, 36 deletions
diff --git a/constraint.go b/constraint.go
deleted file mode 100644
index c08e222..0000000
--- a/constraint.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package gui
-
-// Constraint imposes a restriction on the size of a widget or layout.
-type Constraint struct {
- // Dim is the dimension to constrain: width/height.
- Dim
-
- // Relation declares whether the constraint is an upper, lower, or exact bound.
- Relation
-
- // Length is the target or threshold value.
- Length
-}
-
-// Dim is a dimension of a widget or layout that can be constrained.
-type Dim int
-
-const (
- _ Dim = iota
- Width
- Height
-)
-
-// Relation is an (in)equality.
-type Relation int
-
-const (
- _ Relation = iota
- Eq // ==
- Gteq // >=
- Gt // >
- Lteq // <=
- Lt // <
-)
diff --git a/env.go b/env.go
index 005ef80..68b02d1 100644
--- a/env.go
+++ b/env.go
@@ -3,6 +3,8 @@ package gui
import (
"image"
"image/draw"
+
+ "github.com/faiface/gui/lay/strain"
)
// Env is the most important thing in this package. It is an interactive graphical
@@ -35,7 +37,7 @@ type Env interface {
// enough space.
//
// The Impose() channel may be synchronous.
- Impose() chan<- Constraint
+ Impose() chan<- strain.Constraint
// Close destroys the Env. The Env will subsequently close the Events(), Draw(),
// and Impose() channels.
diff --git a/go.mod b/go.mod
index ba71cfe..a062971 100644
--- a/go.mod
+++ b/go.mod
@@ -7,3 +7,9 @@ require (
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71
github.com/go-gl/glfw v0.0.0-20250301202403-da16c1255728
)
+
+require (
+ github.com/lithdew/casso v0.0.0-20200531104607-fe75aa82181f // indirect
+ golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa // indirect
+ golang.org/x/image v0.36.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 9874aa4..025737b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,18 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 h1:baVdMKlASEHrj19iqjARrPbaRisD7EuZEVJj6ZMLl1Q=
github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3/go.mod h1:VEPNJUlxl5KdWjDvz6Q1l+rJlxF2i6xqDeGuGAxa87M=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw v0.0.0-20250301202403-da16c1255728 h1:Ak0LUgy7whfnJGPcjhR4oJ+THJNkXuhEfa+htfbz90o=
github.com/go-gl/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:fOxQgJvH6dIDHn5YOoXiNC8tUMMNuCgbMK2yZTlZVQA=
+github.com/lithdew/casso v0.0.0-20200531104607-fe75aa82181f h1:BJPLqH5ylnMCrDyViN4w9kHYup2w1BNKbEihSAQpDOk=
+github.com/lithdew/casso v0.0.0-20200531104607-fe75aa82181f/go.mod h1:iaGbC3JZCbafAF9UkQTn2To3bMVOkTXNFTfS2yRvclM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa h1:+7e7RPzOw2fG8DBbddatlOmHGNCg+VlA2Ar0yVMw7sM=
+golang.org/x/exp/shiny v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:zxsA7NyDTOUjcveVwAMFI/YIErWwayTW/4RGqB/RzKk=
+golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
+golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/internal/log/log.go b/internal/log/log.go
new file mode 100644
index 0000000..545cd98
--- /dev/null
+++ b/internal/log/log.go
@@ -0,0 +1,11 @@
+package log
+
+import (
+ "log"
+ "os"
+)
+
+var (
+ Out = log.New(os.Stdout, "faiface/gui [info]: ", log.LstdFlags)
+ Err = log.New(os.Stderr, "faiface/gui [error]: ", log.LstdFlags|log.Llongfile)
+)
diff --git a/internal/tag/tag.go b/internal/tag/tag.go
new file mode 100644
index 0000000..197d2d6
--- /dev/null
+++ b/internal/tag/tag.go
@@ -0,0 +1,12 @@
+package tag
+
+type Tagged[V, T any] struct {
+ Val V
+ Tag T
+}
+
+func Tag[V, T any](out chan<- Tagged[V, T], in <-chan V, f func(V) T) {
+ for val := range in {
+ out <- Tagged[V, T]{val, f(val)}
+ }
+}
diff --git a/lay/strain/bound.go b/lay/strain/bound.go
new file mode 100644
index 0000000..f1d4488
--- /dev/null
+++ b/lay/strain/bound.go
@@ -0,0 +1,28 @@
+package strain
+
+import "golang.org/x/exp/shiny/unit"
+
+// Bounds places the inclusive lower and upper bounds [Min, Max]
+// on a dimension. A negative value means there is no bound.
+// If both Min and Max are non-negative, then it is well formed if
+// Min <= Max.
+type Bounds struct {
+ Min, Max unit.Value
+}
+
+// BoundedPoint is a point with bounds on its horizontal/vertical
+// position.
+type BoundedPoint struct {
+ X, Y Bounds
+}
+
+// Free returns an unconstrained value (negative Min and Max).
+func Free() Bounds {
+ return Bounds{unit.Pixels(-1), unit.Pixels(-1)}
+}
+
+// FreePoint returns an unconstrained point (negative Min and Max
+// in both dimensions).
+func FreePoint() BoundedPoint {
+ return BoundedPoint{Free(), Free()}
+}
diff --git a/lay/strain/constraint.go b/lay/strain/constraint.go
new file mode 100644
index 0000000..44c9e0a
--- /dev/null
+++ b/lay/strain/constraint.go
@@ -0,0 +1,28 @@
+package strain
+
+import (
+ "github.com/lithdew/casso"
+ "golang.org/x/exp/shiny/unit"
+)
+
+// Constraint imposes a restriction on the size of a widget or layout.
+type Constraint struct {
+ // Dimension is the dimension to constrain: width/height.
+ Dimension
+
+ // Relation declares whether the constraint is an upper, lower,
+ // or exact bound.
+ casso.Op
+
+ // Value is the target or threshold value.
+ unit.Value
+}
+
+// Dim is a dimension of a widget or layout that can be constrained.
+type Dimension int
+
+const (
+ _ Dimension = iota
+ Width
+ Height
+)
diff --git a/lay/strain/doc.go b/lay/strain/doc.go
new file mode 100644
index 0000000..4296d70
--- /dev/null
+++ b/lay/strain/doc.go
@@ -0,0 +1,6 @@
+/*
+Package strain uses the Cassowary algorithm to solve systems of
+constraints so that screen space can be partitioned and distributed
+among fields of a layout.
+*/
+package strain
diff --git a/lay/strain/solve.go b/lay/strain/solve.go
new file mode 100644
index 0000000..e1a89d8
--- /dev/null
+++ b/lay/strain/solve.go
@@ -0,0 +1,276 @@
+package strain
+
+import (
+ "context"
+ "fmt"
+ "image"
+
+ "github.com/lithdew/casso"
+
+ "github.com/faiface/gui/internal/log"
+ "github.com/faiface/gui/internal/tag"
+ "github.com/faiface/gui/style"
+)
+
+// Solver uses the Cassowary algorithm to partition a rectangle among
+// several layout fields.
+//
+// Solver uses constraints to control the position and size of fields.
+// There are two sources of constraints: 1) the layout, via
+// AddConstraint(); and 2) the fields, via the Constraint channels
+// passed to NewSolver(). The layout constraints control the position
+// and size of fields within the container, while the field
+// constraints control the width and height of each field.
+type Solver struct {
+ solver *casso.Solver
+
+ // External symbols
+ container SymRect // position and size of container
+ fieldOrigins []SymPt // top-left corner position of each field
+ fieldSizes []SymPt // width and height of each field
+
+ fieldSizeConstrs []sizeConstraint
+
+ fieldConstrs chan tag.Tagged[Constraint, int] // constraints from fields, tagged by index
+ layoutConstrs chan constrainRequest // constraints from the layout via AddConstraint()
+ solveReqs chan solveRequest
+
+ style style.Style
+
+ ctx context.Context
+ cancel func()
+}
+
+// sizeConstraint sets upper/lower/exact bounds on the width and
+// height of a field.
+type sizeConstraint struct {
+ widthEq, widthGte, widthLte *casso.Symbol
+ heightEq, heightGte, heightLte *casso.Symbol
+
+ // These are the return values of casso.Solver.AddConstraint().
+ // Eq is mutually exclusive with both gte and lte. But gte and lte are
+ // not mutually exclusive (can have both a lower and upper bound).
+ // Nil means no constraint.
+}
+
+type constrainRequest struct {
+ constr casso.Constraint
+ res chan<- error
+}
+
+type solveRequest struct {
+ container image.Rectangle
+ res chan<- solveResponse
+}
+
+type solveResponse struct {
+ fields []image.Rectangle
+ err error
+}
+
+// NewSolver creates a Solver that can be used to resolve constraints
+// received from the given channels; one channel per field in the
+// layout. These are generally the receiving side of some Envs'
+// Impose() channels.
+//
+// The Solver should be closed after use, but not before all of the
+// constraint channels have been closed.
+func NewSolver(styl style.Style, constraints []<-chan Constraint) (*Solver, error) {
+ nfields := len(constraints)
+
+ fieldConstrs := make(chan tag.Tagged[Constraint, int])
+ fieldOrigins := make([]SymPt, nfields)
+ fieldSizes := make([]SymPt, nfields)
+ for i, cs := range constraints {
+ go tag.Tag(fieldConstrs, cs, func(c Constraint) int { return i })
+ fieldOrigins[i] = NewSymPt()
+ fieldSizes[i] = NewSymPt()
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ solver := &Solver{
+ casso.NewSolver(),
+ NewSymRect(),
+ fieldOrigins,
+ fieldSizes,
+ make([]sizeConstraint, nfields),
+ fieldConstrs,
+ make(chan constrainRequest),
+ make(chan solveRequest),
+ styl,
+ ctx,
+ cancel,
+ }
+ if err := editRect(solver.solver, solver.container, casso.Required); err != nil {
+ return nil, err
+ }
+
+ go solver.run()
+
+ return solver, nil
+}
+
+func (s *Solver) run() {
+ defer close(s.fieldConstrs)
+ defer close(s.layoutConstrs)
+ defer close(s.solveReqs)
+
+ for {
+ select {
+ case tc := <-s.fieldConstrs: // constraint from a field, tagged by field index
+ constr, fieldIdx := tc.Val, tc.Tag
+ err := s.addSizeConstraint(constr, fieldIdx)
+ if err != nil {
+ log.Err.Printf("error adding layout constraint %#v from field %d: %v\n",
+ constr, fieldIdx, err)
+ }
+
+ case req := <-s.layoutConstrs: // constraint from the layout via AddConstraint()
+ _, err := s.solver.AddConstraint(req.constr)
+ req.res <- err
+
+ case req := <-s.solveReqs:
+ fields, err := s.solve(req.container)
+ req.res <- solveResponse{fields, err}
+
+ case <-s.ctx.Done():
+ return
+ }
+ }
+}
+
+// addSizeConstraint adds or modifies a constraint on the size of a
+// field, removing mutually exclusive constraints.
+func (s *Solver) addSizeConstraint(constr Constraint, fieldIdx int) error {
+ fieldSize := s.fieldSizes[fieldIdx]
+ fieldConstrs := &s.fieldSizeConstrs[fieldIdx]
+
+ // Clear mutually exclusive constraints and replace with new one
+ switch constr.Dim {
+ case Width:
+ width := s.style.Pixels(constr.Value).Round()
+ switch constr.Op {
+ case casso.EQ:
+ s.removeConstraints(fieldConstrs.widthEq, fieldConstrs.widthGte, fieldConstrs.widthLte)
+ c, err := s.solver.AddConstraint(casso.NewConstraint(constr.Op, -width, fieldSize.X.T(1.0)))
+ if err != nil {
+ return err
+ }
+ fieldConstrs.widthEq = &c
+ case casso.GTE:
+ s.removeConstraints(fieldConstrs.widthEq, fieldConstrs.widthGte)
+ c, err := s.solver.AddConstraint(casso.NewConstraint(constr.Op, -width, fieldSize.X.T(1.0)))
+ if err != nil {
+ return err
+ }
+ fieldConstrs.widthGte = &c
+ case casso.LTE:
+ s.removeConstraints(fieldConstrs.widthEq, fieldConstrs.widthLte)
+ c, err := s.solver.AddConstraint(casso.NewConstraint(constr.Op, -width, fieldSize.X.T(1.0)))
+ if err != nil {
+ return err
+ }
+ fieldConstrs.widthLte = &c
+ default:
+ panic(fmt.Sprintf("unreachable: impossible %T: %v", constr.Op, constr.Op))
+ }
+ case Height:
+ height := s.style.Pixels(constr.Value).Round()
+ switch constr.Op {
+ case casso.EQ:
+ s.removeConstraints(fieldConstrs.heightEq, fieldConstrs.heightGte, fieldConstrs.heightLte)
+ c, err := s.solver.AddConstraint(casso.NewConstraint(constr.Op, -height, fieldSize.Y.T(1.0)))
+ if err != nil {
+ return err
+ }
+ fieldConstrs.heightEq = &c
+ case casso.GTE:
+ s.removeConstraints(fieldConstrs.heightEq, fieldConstrs.heightGte)
+ c, err := s.solver.AddConstraint(casso.NewConstraint(constr.Op, -height, fieldSize.Y.T(1.0)))
+ if err != nil {
+ return err
+ }
+ fieldConstrs.heightGte = &c
+ case casso.LTE:
+ s.removeConstraints(fieldConstrs.heightEq, fieldConstrs.heightLte)
+ c, err := s.solver.AddConstraint(casso.NewConstraint(constr.Op, -height, fieldSize.Y.T(1.0)))
+ if err != nil {
+ return err
+ }
+ fieldConstrs.heightLte = &c
+ default:
+ panic(fmt.Sprintf("unreachable: impossible %T: %v", constr.Op, constr.Op))
+ }
+ default:
+ panic(fmt.Sprintf("unreachable: impossible %T: %v", constr.Dim, constr.Dim))
+ }
+
+ return nil
+}
+
+func (s *Solver) removeConstraints(constrs ...*casso.Symbol) error {
+ for _, constr := range constrs {
+ if constr != nil {
+ if err := s.solver.RemoveConstraint(*constr); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func (s *Solver) solve(container image.Rectangle) (fields []image.Rectangle, err error) {
+ if err := suggestRect(s.solver, s.container, container); err != nil {
+ return nil, err
+ }
+ fields = make([]image.Rectangle, len(s.fieldOrigins))
+ for i := range fields {
+ origin, size := s.fieldOrigins[i], s.fieldSizes[i]
+ min := image.Pt(int(s.solver.Val(origin.X)), int(s.solver.Val(origin.Y)))
+ max := min.Add(image.Pt(int(s.solver.Val(size.X)), int(s.solver.Val(size.Y))))
+ fields[i] = image.Rectangle{min, max}
+ }
+ return fields, nil
+}
+
+// Close destroys the solver. You must first close all the Constraint
+// channels that were passed to NewSolver() before calling Close.
+func (s *Solver) Close() { s.cancel() }
+
+// Container returns the Cassowary symbols representing the layout
+// container's position and size.
+func (s *Solver) Container() SymRect { return s.container }
+
+// Field returns the Cassowary symbols representing the i'th field's
+// position and size. origin represents the position of the top-left
+// corner of the field. size represents the width and height of the
+// field.
+func (s *Solver) Field(i int) (origin, size SymPt) { return s.fieldOrigins[i], s.fieldSizes[i] }
+
+// AddConstraint imposes a constraint between two symbols. The symbols
+// may be aspects of the Container() or Field()s.
+//
+// Once added, a constraint cannot be removed or modified.
+func (s *Solver) AddConstraint(op casso.Op, lhs, rhs casso.Symbol) error {
+ res := make(chan error)
+ defer close(res)
+ s.layoutConstrs <- constrainRequest{
+ casso.NewConstraint(op, 0, lhs.T(1.0), rhs.T(-1.0)),
+ res,
+ }
+ return <-res
+}
+
+// Solve uses the constraints that the Solver has received so far to
+// partition container, dividing it among the fields of the layout.
+//
+// It returns a slice of Rectangles, one per field. The slice has the
+// same length as the slice of Constraint channels that were passed to
+// NewSolver().
+func (s *Solver) Solve(container image.Rectangle) ([]image.Rectangle, error) {
+ resc := make(chan solveResponse)
+ defer close(resc)
+ s.solveReqs <- solveRequest{container, resc}
+ res := <-resc
+ return res.fields, res.err
+}
diff --git a/lay/strain/sym.go b/lay/strain/sym.go
new file mode 100644
index 0000000..cf8777b
--- /dev/null
+++ b/lay/strain/sym.go
@@ -0,0 +1,70 @@
+package strain
+
+import (
+ "image"
+
+ "github.com/lithdew/casso"
+)
+
+// SymPt is a set of Cassowary symbols representing an image.Point.
+type SymPt struct {
+ X, Y casso.Symbol
+}
+
+// SymRect is a set of Cassowary symbols representing an
+// image.Rectangle.
+type SymRect struct {
+ Min, Max SymPt
+}
+
+func NewSymPt() SymPt {
+ return SymPt{casso.New(), casso.New()}
+}
+
+func NewSymRect() SymRect {
+ return SymRect{NewSymPt(), NewSymPt()}
+}
+
+// editRect marks all symbols of a rectangle as editable with a
+// certain precedence.
+func editRect(solver *casso.Solver, sr SymRect, p casso.Priority) error {
+ if err := editPt(solver, sr.Min, p); err != nil {
+ return err
+ }
+ if err := editPt(solver, sr.Max, p); err != nil {
+ return err
+ }
+ return nil
+}
+
+// editRect marks both symbols of a point as editable with a certain
+// precedence.
+func editPt(solver *casso.Solver, sp SymPt, p casso.Priority) error {
+ if err := solver.Edit(sp.X, p); err != nil {
+ return err
+ }
+ if err := solver.Edit(sp.Y, p); err != nil {
+ return err
+ }
+ return nil
+}
+
+func suggestRect(solver *casso.Solver, sr SymRect, r image.Rectangle) error {
+ if err := suggestPt(solver, sr.Min, r.Min); err != nil {
+ return err
+ }
+ if err := suggestPt(solver, sr.Max, r.Max); err != nil {
+ return err
+ }
+ return nil
+}
+
+func suggestPt(solver *casso.Solver, sp SymPt, p image.Point) error {
+ if err := solver.Suggest(sp.X, float64(p.X)); err != nil {
+ return err
+ }
+ if err := solver.Suggest(sp.Y, float64(p.Y)); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/mux.go b/mux.go
index c3a8149..d251793 100644
--- a/mux.go
+++ b/mux.go
@@ -3,6 +3,8 @@ package gui
import (
"image"
"image/draw"
+
+ "github.com/faiface/gui/lay/strain"
)
// Mux can be used to multiplex an Env, let's call it a root Env. Mux implements a way to
@@ -11,7 +13,7 @@ import (
type Mux struct {
eventsIns chan chan<- Event
draw chan<- func(draw.Image) image.Rectangle
- impose chan<- Constraint
+ impose chan<- strain.Constraint
finish chan<- struct{}
}
diff --git a/style/style.go b/style/style.go
new file mode 100644
index 0000000..5b74952
--- /dev/null
+++ b/style/style.go
@@ -0,0 +1,22 @@
+package style
+
+import (
+ "golang.org/x/exp/shiny/unit"
+ "golang.org/x/image/math/fixed"
+)
+
+// TODO
+type Style struct {
+}
+
+// Convert implements the golang.org/x/exp/shiny/unit.Converter
+// interface.
+func (s *Style) Convert(v unit.Value, to unit.Unit) unit.Value {
+ // TODO
+}
+
+// Pixels implements the golang.org/x/exp/shiny/unit.Converter
+// interface.
+func (s *Style) Pixels(v unit.Value) fixed.Int26_6 {
+ // TODO
+}