diff options
| author | Sam Anthony <sam@samanthony.xyz> | 2026-03-02 17:38:55 -0500 |
|---|---|---|
| committer | Sam Anthony <sam@samanthony.xyz> | 2026-03-02 17:38:55 -0500 |
| commit | 8858a54b5ddb3a2d8a42ecb1a837c02800bc934f (patch) | |
| tree | 2428c445936eb8865c0b7c8f52560e97a44082f3 | |
| parent | c6cd37a6b80d4e5ad1dbf8c8968b818811b8aa54 (diff) | |
| download | gui-8858a54b5ddb3a2d8a42ecb1a837c02800bc934f.zip | |
create lay/strain package
The lay/strain package uses the Cassowary algorithm to solve systems of
layout constraints.
| -rw-r--r-- | constraint.go | 34 | ||||
| -rw-r--r-- | env.go | 4 | ||||
| -rw-r--r-- | go.mod | 6 | ||||
| -rw-r--r-- | go.sum | 12 | ||||
| -rw-r--r-- | internal/log/log.go | 11 | ||||
| -rw-r--r-- | internal/tag/tag.go | 12 | ||||
| -rw-r--r-- | lay/strain/bound.go | 28 | ||||
| -rw-r--r-- | lay/strain/constraint.go | 28 | ||||
| -rw-r--r-- | lay/strain/doc.go | 6 | ||||
| -rw-r--r-- | lay/strain/solve.go | 276 | ||||
| -rw-r--r-- | lay/strain/sym.go | 70 | ||||
| -rw-r--r-- | mux.go | 4 | ||||
| -rw-r--r-- | style/style.go | 22 |
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 // < -) @@ -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. @@ -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 +) @@ -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 +} @@ -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 +} |