aboutsummaryrefslogtreecommitdiffstats
path: root/lay
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 /lay
parentc6cd37a6b80d4e5ad1dbf8c8968b818811b8aa54 (diff)
downloadgui-8858a54b5ddb3a2d8a42ecb1a837c02800bc934f.zip
create lay/strain package
The lay/strain package uses the Cassowary algorithm to solve systems of layout constraints.
Diffstat (limited to 'lay')
-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
5 files changed, 408 insertions, 0 deletions
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
+}