// SPDX-License-Identifier: Unlicense OR MIT // Layout system: constraints, dimensions, flex, stack, list, inset, direction. // Ported from gioui.org/layout and gioui.org/op (public wrappers). package gio import ( "image" "math" "time" ) // OpOffset converts an integer offset to a TransformOp. func OpOffset(off image.Point) TransformOp { return AffineTransform(AffineId().Offset(Pt(float32(off.X), float32(off.Y)))) } // InputSource is a stub for input.Source (Phase 3). type InputSource struct{} func (s InputSource) Disabled() InputSource { return InputSource{} } // --- Layout types (from layout/layout.go) --- // Constraints represent the minimum and maximum size of a widget. type Constraints struct { Min, Max image.Point } // Dimensions are the resolved size and baseline for a widget. type Dimensions struct { Size image.Point Baseline int } // Axis is the layout axis (horizontal or vertical). type Axis uint8 // Alignment is the mutual alignment of a list of widgets. type Alignment uint8 // Direction is the alignment of widgets relative to a containing space. type Direction uint8 // Widget is a function scope for drawing, processing events and // computing dimensions for a user interface element. type Widget func(gtx LayoutContext) Dimensions const ( Start Alignment = iota End Middle Baseline ) const ( NW Direction = iota N NE E SE S SW W Center ) const ( AxisHorizontal Axis = iota AxisVertical ) // Exact returns Constraints with min and max set to size. func Exact(size image.Point) Constraints { return Constraints{ Min: size, Max: size, } } // LayoutFPt converts an image.Point to a Point (f32). // Note: FPt already exists in f32.mx, this is the layout-specific alias. func LayoutFPt(p image.Point) Point { return Point{ X: float32(p.X), Y: float32(p.Y), } } // Constrain a size so each dimension is in the range [min;max]. func (c Constraints) Constrain(size image.Point) image.Point { if min := c.Min.X; size.X < min { size.X = min } if min := c.Min.Y; size.Y < min { size.Y = min } if max := c.Max.X; size.X > max { size.X = max } if max := c.Max.Y; size.Y > max { size.Y = max } return size } // AddMin returns a copy of Constraints with the Min enlarged by up to delta // while still fitting within Max. func (c Constraints) AddMin(delta image.Point) Constraints { c.Min = c.Min.Add(delta) if c.Min.X < 0 { c.Min.X = 0 } if c.Min.Y < 0 { c.Min.Y = 0 } c.Min = c.Constrain(c.Min) return c } // SubMax returns a copy of Constraints with the Max shrunk by up to delta. func (c Constraints) SubMax(delta image.Point) Constraints { c.Max = c.Max.Sub(delta) if c.Max.X < 0 { c.Max.X = 0 } if c.Max.Y < 0 { c.Max.Y = 0 } c.Min = c.Constrain(c.Min) return c } // --- Context (from layout/context.go) --- // LayoutContext carries the state needed by almost all layouts and widgets. type LayoutContext struct { Constraints Constraints Metric Metric Now time.Time Locale Locale Values map[string]any Source InputSource Ops *OpList } // Dp converts v to pixels. func (c LayoutContext) Dp(v Dp) int { return c.Metric.Dp(v) } // Sp converts v to pixels. func (c LayoutContext) Sp(v Sp) int { return c.Metric.Sp(v) } // Disabled returns a copy of this context with no event delivery. func (c LayoutContext) Disabled() LayoutContext { c.Source = c.Source.Disabled() return c } // --- Inset (from layout/layout.go) --- // Inset adds space around a widget by decreasing its maximum constraints. type Inset struct { Top, Bottom, Left, Right Dp } // Layout a widget with inset. func (in Inset) Layout(gtx LayoutContext, w Widget) Dimensions { top := gtx.Dp(in.Top) right := gtx.Dp(in.Right) bottom := gtx.Dp(in.Bottom) left := gtx.Dp(in.Left) mcs := gtx.Constraints mcs.Max.X -= left + right if mcs.Max.X < 0 { left = 0 right = 0 mcs.Max.X = 0 } if mcs.Min.X > mcs.Max.X { mcs.Min.X = mcs.Max.X } mcs.Max.Y -= top + bottom if mcs.Max.Y < 0 { bottom = 0 top = 0 mcs.Max.Y = 0 } if mcs.Min.Y > mcs.Max.Y { mcs.Min.Y = mcs.Max.Y } gtx.Constraints = mcs trans := OpOffset(image.Pt(left, top)).Push(gtx.Ops) dims := w(gtx) trans.Pop() return Dimensions{ Size: dims.Size.Add(image.Point{X: right + left, Y: top + bottom}), Baseline: dims.Baseline + bottom, } } // UniformInset returns an Inset with a single value applied to all edges. func UniformInset(v Dp) Inset { return Inset{Top: v, Right: v, Bottom: v, Left: v} } // --- Direction layout (from layout/layout.go) --- // Layout a widget according to the direction. func (d Direction) Layout(gtx LayoutContext, w Widget) Dimensions { macro := Record(gtx.Ops) csn := gtx.Constraints.Min switch d { case N, S: gtx.Constraints.Min.Y = 0 case E, W: gtx.Constraints.Min.X = 0 default: gtx.Constraints.Min = image.Point{} } dims := w(gtx) call := macro.Stop() sz := dims.Size if sz.X < csn.X { sz.X = csn.X } if sz.Y < csn.Y { sz.Y = csn.Y } p := d.Position(dims.Size, sz) defer OpOffset(p).Push(gtx.Ops).Pop() call.Add(gtx.Ops) return Dimensions{ Size: sz, Baseline: dims.Baseline + sz.Y - dims.Size.Y - p.Y, } } // Position calculates widget position according to the direction. func (d Direction) Position(widget, bounds image.Point) image.Point { var p image.Point switch d { case N, S, Center: p.X = (bounds.X - widget.X) / 2 case NE, SE, E: p.X = bounds.X - widget.X } switch d { case W, Center, E: p.Y = (bounds.Y - widget.Y) / 2 case SW, S, SE: p.Y = bounds.Y - widget.Y } return p } // --- Spacer (from layout/layout.go) --- // Spacer adds space between widgets. type Spacer struct { Width, Height Dp } func (s Spacer) Layout(gtx LayoutContext) Dimensions { return Dimensions{ Size: gtx.Constraints.Constrain(image.Point{ X: gtx.Dp(s.Width), Y: gtx.Dp(s.Height), }), } } // --- Axis utilities (from layout/layout.go) --- // Convert a point in (x, y) coordinates to (main, cross) coordinates, // or vice versa. func (a Axis) Convert(pt image.Point) image.Point { if a == AxisHorizontal { return pt } return image.Pt(pt.Y, pt.X) } // FConvert a point in (x, y) coordinates to (main, cross) coordinates, // or vice versa, for f32 points. func (a Axis) FConvert(pt Point) Point { if a == AxisHorizontal { return pt } return Pt(pt.Y, pt.X) } func (a Axis) mainConstraint(cs Constraints) (int, int) { if a == AxisHorizontal { return cs.Min.X, cs.Max.X } return cs.Min.Y, cs.Max.Y } func (a Axis) crossConstraint(cs Constraints) (int, int) { if a == AxisHorizontal { return cs.Min.Y, cs.Max.Y } return cs.Min.X, cs.Max.X } func (a Axis) constraints(mainMin, mainMax, crossMin, crossMax int) Constraints { if a == AxisHorizontal { return Constraints{Min: image.Pt(mainMin, crossMin), Max: image.Pt(mainMax, crossMax)} } return Constraints{Min: image.Pt(crossMin, mainMin), Max: image.Pt(crossMax, mainMax)} } func (a Axis) String() string { switch a { case AxisHorizontal: return "Horizontal" case AxisVertical: return "Vertical" default: panic("unreachable") } } func (al Alignment) String() string { switch al { case Start: return "Start" case End: return "End" case Middle: return "Middle" case Baseline: return "Baseline" default: panic("unreachable") } } func (d Direction) String() string { switch d { case NW: return "NW" case N: return "N" case NE: return "NE" case E: return "E" case SE: return "SE" case S: return "S" case SW: return "SW" case W: return "W" case Center: return "Center" default: panic("unreachable") } } // --- Flex (from layout/flex.go) --- // Flex lays out child elements along an axis, // according to alignment and weights. type Flex struct { Axis Axis Spacing Spacing Alignment Alignment WeightSum float32 Gap int } // FlexChild is the descriptor for a Flex child. type FlexChild struct { flex bool weight float32 widget Widget } // Spacing determines the spacing mode for a Flex. type Spacing uint8 const ( SpaceEnd Spacing = iota SpaceStart SpaceSides SpaceAround SpaceBetween SpaceEvenly ) // Rigid returns a Flex child with a maximal constraint of the // remaining space. func Rigid(widget Widget) FlexChild { return FlexChild{ widget: widget, } } // Flexed returns a Flex child forced to take up weight fraction of the // space left over from Rigid children. func Flexed(weight float32, widget Widget) FlexChild { return FlexChild{ flex: true, weight: weight, widget: widget, } } // Layout a list of children. Rigid children are laid out before Flexed children. func (f Flex) Layout(gtx LayoutContext, children ...FlexChild) Dimensions { size := 0 cs := gtx.Constraints mainMin, mainMax := f.Axis.mainConstraint(cs) crossMin, crossMax := f.Axis.crossConstraint(cs) remaining := mainMax if len(children) > 1 && f.Gap > 0 { totalGap := f.Gap * (len(children) - 1) remaining -= totalGap if remaining < 0 { remaining = 0 } } var totalWeight float32 cgtx := gtx type scratchSpace struct { call CallOp dims Dimensions } var scratchArray [32]scratchSpace scratch := scratchArray[:0] scratch = append(scratch, []scratchSpace{:len(children)}...) // Lay out Rigid children. for i, child := range children { if child.flex { totalWeight += child.weight continue } macro := Record(gtx.Ops) cgtx.Constraints = f.Axis.constraints(0, remaining, crossMin, crossMax) dims := child.widget(cgtx) c := macro.Stop() sz := f.Axis.Convert(dims.Size).X size += sz remaining -= sz if remaining < 0 { remaining = 0 } scratch[i].call = c scratch[i].dims = dims } if w := f.WeightSum; w != 0 { totalWeight = w } var fraction float32 flexTotal := remaining // Lay out Flexed children. for i, child := range children { if !child.flex { continue } var flexSize int if remaining > 0 && totalWeight > 0 { childSize := float32(flexTotal) * child.weight / totalWeight flexSize = int(childSize + fraction + .5) fraction = childSize - float32(flexSize) if flexSize > remaining { flexSize = remaining } } macro := Record(gtx.Ops) cgtx.Constraints = f.Axis.constraints(flexSize, flexSize, crossMin, crossMax) dims := child.widget(cgtx) c := macro.Stop() sz := f.Axis.Convert(dims.Size).X size += sz remaining -= sz if remaining < 0 { remaining = 0 } scratch[i].call = c scratch[i].dims = dims } maxCross := crossMin var maxBaseline int for _, scratchChild := range scratch { if c := f.Axis.Convert(scratchChild.dims.Size).Y; c > maxCross { maxCross = c } if b := scratchChild.dims.Size.Y - scratchChild.dims.Baseline; b > maxBaseline { maxBaseline = b } } if len(children) > 1 && f.Gap > 0 { size += f.Gap * (len(children) - 1) } var space int if mainMin > size { space = mainMin - size } var mainSize int switch f.Spacing { case SpaceSides: mainSize += space / 2 case SpaceStart: mainSize += space case SpaceEvenly: mainSize += space / (1 + len(children)) case SpaceAround: if len(children) > 0 { mainSize += space / (len(children) * 2) } } for i, scratchChild := range scratch { dims := scratchChild.dims b := dims.Size.Y - dims.Baseline var cross int switch f.Alignment { case End: cross = maxCross - f.Axis.Convert(dims.Size).Y case Middle: cross = (maxCross - f.Axis.Convert(dims.Size).Y) / 2 case Baseline: if f.Axis == AxisHorizontal { cross = maxBaseline - b } } pt := f.Axis.Convert(image.Pt(mainSize, cross)) trans := OpOffset(pt).Push(gtx.Ops) scratchChild.call.Add(gtx.Ops) trans.Pop() mainSize += f.Axis.Convert(dims.Size).X if i < len(children)-1 { mainSize += f.Gap switch f.Spacing { case SpaceEvenly: mainSize += space / (1 + len(children)) case SpaceAround: if len(children) > 0 { mainSize += space / len(children) } case SpaceBetween: if len(children) > 1 { mainSize += space / (len(children) - 1) } } } } switch f.Spacing { case SpaceSides: mainSize += space / 2 case SpaceEnd: mainSize += space case SpaceEvenly: mainSize += space / (1 + len(children)) case SpaceAround: if len(children) > 0 { mainSize += space / (len(children) * 2) } } sz := f.Axis.Convert(image.Pt(mainSize, maxCross)) sz = cs.Constrain(sz) return Dimensions{Size: sz, Baseline: sz.Y - maxBaseline} } func (s Spacing) String() string { switch s { case SpaceEnd: return "SpaceEnd" case SpaceStart: return "SpaceStart" case SpaceSides: return "SpaceSides" case SpaceAround: return "SpaceAround" case SpaceBetween: return "SpaceBetween" case SpaceEvenly: return "SpaceEvenly" default: panic("unreachable") } } // --- Stack (from layout/stack.go) --- // LayoutStack lays out child elements on top of each other, // according to an alignment direction. type LayoutStack struct { Alignment Direction } // StackChild represents a child for a LayoutStack layout. type StackChild struct { expanded bool widget Widget } // Stacked returns a StackChild laid out with no minimum constraints. func Stacked(w Widget) StackChild { return StackChild{ widget: w, } } // Expanded returns a StackChild with min constraints set // to the largest Stacked child. func Expanded(w Widget) StackChild { return StackChild{ expanded: true, widget: w, } } // Layout a stack of children. Stacked children are laid out before Expanded children. func (s LayoutStack) Layout(gtx LayoutContext, children ...StackChild) Dimensions { var maxSZ image.Point cgtx := gtx cgtx.Constraints.Min = image.Point{} type scratchSpace struct { call CallOp dims Dimensions } var scratchArray [32]scratchSpace scratch := scratchArray[:0] scratch = append(scratch, []scratchSpace{:len(children)}...) for i, w := range children { if w.expanded { continue } macro := Record(gtx.Ops) dims := w.widget(cgtx) call := macro.Stop() if w := dims.Size.X; w > maxSZ.X { maxSZ.X = w } if h := dims.Size.Y; h > maxSZ.Y { maxSZ.Y = h } scratch[i].call = call scratch[i].dims = dims } for i, w := range children { if !w.expanded { continue } macro := Record(gtx.Ops) cgtx.Constraints.Min = maxSZ dims := w.widget(cgtx) call := macro.Stop() if w := dims.Size.X; w > maxSZ.X { maxSZ.X = w } if h := dims.Size.Y; h > maxSZ.Y { maxSZ.Y = h } scratch[i].call = call scratch[i].dims = dims } maxSZ = gtx.Constraints.Constrain(maxSZ) var baseline int for _, scratchChild := range scratch { sz := scratchChild.dims.Size var p image.Point switch s.Alignment { case N, S, Center: p.X = (maxSZ.X - sz.X) / 2 case NE, SE, E: p.X = maxSZ.X - sz.X } switch s.Alignment { case W, Center, E: p.Y = (maxSZ.Y - sz.Y) / 2 case SW, S, SE: p.Y = maxSZ.Y - sz.Y } trans := OpOffset(p).Push(gtx.Ops) scratchChild.call.Add(gtx.Ops) trans.Pop() if baseline == 0 { if b := scratchChild.dims.Baseline; b != 0 { baseline = b + maxSZ.Y - sz.Y - p.Y } } } return Dimensions{ Size: maxSZ, Baseline: baseline, } } // Background lays out a single child widget on top of a background. type Background struct{} // Layout a widget and then add a background to it. func (Background) Layout(gtx LayoutContext, background, widget Widget) Dimensions { macro := Record(gtx.Ops) wdims := widget(gtx) baseline := wdims.Baseline call := macro.Stop() cgtx := gtx cgtx.Constraints.Min = gtx.Constraints.Constrain(wdims.Size) bdims := background(cgtx) if bdims.Size != wdims.Size { p := image.Point{ X: (bdims.Size.X - wdims.Size.X) / 2, Y: (bdims.Size.Y - wdims.Size.Y) / 2, } baseline += (bdims.Size.Y - wdims.Size.Y) / 2 trans := OpOffset(p).Push(gtx.Ops) defer trans.Pop() } call.Add(gtx.Ops) return Dimensions{ Size: bdims.Size, Baseline: baseline, } } // --- List (from layout/list.go) --- type scrollChild struct { size image.Point call CallOp } // LayoutList displays a subsection of a potentially infinitely // large underlying list. Accepts user input to scroll the subsection. type LayoutList struct { Axis Axis ScrollToEnd bool Alignment Alignment ScrollAnyAxis bool Gap int cs Constraints scroll GestureScroll scrollDelta int Position Position len int maxSize int children []scrollChild dir iterationDir } // ListElement computes the dimensions of a list element. type ListElement func(gtx LayoutContext, index int) Dimensions type iterationDir uint8 // Position is a List scroll offset. type Position struct { BeforeEnd bool First int Offset int OffsetLast int Count int Length int } const ( iterateNone iterationDir = iota iterateForward iterateBackward ) const listInf = 1e6 func (l *LayoutList) init(gtx LayoutContext, length int) { if l.more() { panic("unfinished child") } l.cs = gtx.Constraints l.maxSize = 0 l.children = l.children[:0] l.len = length l.update(gtx) if l.Position.First < 0 { l.Position.Offset = 0 l.Position.First = 0 } if l.scrollToEnd() || l.Position.First > length { l.Position.Offset = 0 l.Position.First = length } } // Layout a List of length items, where each item is implicitly defined // by the callback w. func (l *LayoutList) Layout(gtx LayoutContext, length int, w ListElement) Dimensions { l.init(gtx, length) crossMin, crossMax := l.Axis.crossConstraint(gtx.Constraints) gtx.Constraints = l.Axis.constraints(0, int(listInf), crossMin, crossMax) macro := Record(gtx.Ops) laidOutTotalLength := 0 numLaidOut := 0 for l.next(); l.more(); l.next() { child := Record(gtx.Ops) dims := w(gtx, l.index()) call := child.Stop() l.end(dims, call) laidOutTotalLength += l.Axis.Convert(dims.Size).X numLaidOut++ } if numLaidOut > 0 { l.Position.Length = laidOutTotalLength*length/numLaidOut + l.Gap*(length-1) } else { l.Position.Length = 0 } return l.layout(gtx.Ops, macro) } func (l *LayoutList) scrollToEnd() bool { return l.ScrollToEnd && !l.Position.BeforeEnd } // Dragging reports whether the List is being dragged. func (l *LayoutList) Dragging() bool { return l.scroll.ScrollState() == GestureScrollDragging } func (l *LayoutList) update(gtx LayoutContext) { axis := GestureAxisHorizontal if l.Axis == AxisVertical { axis = GestureAxisVertical } if l.ScrollAnyAxis { axis = GestureAxisBoth } d := l.scroll.Update(Source{}, gtx.Metric, gtx.Now, axis) l.scrollDelta = d l.Position.Offset += d } func (l *LayoutList) next() { l.dir = l.nextDir() if l.scrollToEnd() && !l.more() && l.scrollDelta < 0 { l.Position.BeforeEnd = true l.Position.Offset += l.scrollDelta l.dir = l.nextDir() } } func (l *LayoutList) index() int { switch l.dir { case iterateBackward: return l.Position.First - 1 case iterateForward: return l.Position.First + len(l.children) default: panic("Index called before Next") } } func (l *LayoutList) more() bool { return l.dir != iterateNone } func (l *LayoutList) nextDir() iterationDir { _, vsize := l.Axis.mainConstraint(l.cs) last := l.Position.First + len(l.children) if l.maxSize-l.Position.Offset < vsize && last == l.len { l.Position.Offset = l.maxSize - vsize } if l.Position.Offset < 0 && l.Position.First == 0 { l.Position.Offset = 0 } firstSize, lastSize := 0, 0 if len(l.children) > 0 { if l.Position.First > 0 { firstChild := l.children[0] firstSize = l.Axis.Convert(firstChild.size).X + l.Gap } if last < l.len { lastChild := l.children[len(l.children)-1] lastSize = l.Axis.Convert(lastChild.size).X + l.Gap } } switch { case len(l.children) == l.len: return iterateNone case l.maxSize-l.Position.Offset-lastSize < vsize: return iterateForward case l.Position.Offset-firstSize < 0: return iterateBackward } return iterateNone } func (l *LayoutList) end(dims Dimensions, call CallOp) { child := scrollChild{dims.Size, call} mainSize := l.Axis.Convert(child.size).X if len(l.children) > 0 { l.maxSize += l.Gap } l.maxSize += mainSize switch l.dir { case iterateForward: l.children = append(l.children, child) case iterateBackward: l.children = append(l.children, scrollChild{}) copy(l.children[1:], l.children) l.children[0] = child l.Position.First-- l.Position.Offset += mainSize + l.Gap default: panic("call Next before End") } l.dir = iterateNone } func (l *LayoutList) layout(ops *OpList, macro MacroOp) Dimensions { if l.more() { panic("unfinished child") } mainMin, mainMax := l.Axis.mainConstraint(l.cs) children := l.children var first scrollChild for len(children) > 0 { child := children[0] sz := child.size mainSize := l.Axis.Convert(sz).X if l.Position.Offset < mainSize { break } l.Position.First++ l.Position.Offset -= mainSize + l.Gap first = child children = children[1:] } size := -l.Position.Offset var maxCross int var last scrollChild for i, child := range children { sz := l.Axis.Convert(child.size) if c := sz.Y; c > maxCross { maxCross = c } if i > 0 { size += l.Gap } size += sz.X if size >= mainMax { if i < len(children)-1 { last = children[i+1] } children = children[:i+1] break } } l.Position.Count = len(children) l.Position.OffsetLast = mainMax - size if space := l.Position.OffsetLast; l.ScrollToEnd && space > 0 { l.Position.Offset -= space } pos := -l.Position.Offset layoutChild := func(child scrollChild) { sz := l.Axis.Convert(child.size) var cross int switch l.Alignment { case End: cross = maxCross - sz.Y case Middle: cross = (maxCross - sz.Y) / 2 } childSize := sz.X pt := l.Axis.Convert(image.Pt(pos, cross)) trans := OpOffset(pt).Push(ops) child.call.Add(ops) trans.Pop() pos += childSize } // Lay out leading invisible child. if first != (scrollChild{}) { sz := l.Axis.Convert(first.size) pos -= sz.X + l.Gap layoutChild(first) pos += l.Gap } for i, child := range children { if i > 0 { pos += l.Gap } layoutChild(child) } // Lay out trailing invisible child. if last != (scrollChild{}) { pos += l.Gap layoutChild(last) } atStart := l.Position.First == 0 && l.Position.Offset <= 0 atEnd := l.Position.First+len(children) == l.len && mainMax >= pos if atStart && l.scrollDelta < 0 || atEnd && l.scrollDelta > 0 { l.scroll.Stop() } l.Position.BeforeEnd = !atEnd if pos < mainMin { pos = mainMin } if pos > mainMax { pos = mainMax } if crossMin, crossMax := l.Axis.crossConstraint(l.cs); maxCross < crossMin { maxCross = crossMin } else if maxCross > crossMax { maxCross = crossMax } dims := l.Axis.Convert(image.Pt(pos, maxCross)) call := macro.Stop() defer ClipRect(image.Rectangle{Max: dims}).Push(ops).Pop() call.Add(ops) return Dimensions{Size: dims} } // ScrollBy scrolls the list by a relative amount of items. func (l *LayoutList) ScrollBy(num float32) { i, f := math.Modf(float64(num)) l.Position.First += int(i) itemHeight := float64(l.Position.Length) / float64(l.len) l.Position.Offset += int(math.Round(itemHeight * f)) l.Position.BeforeEnd = true } // ScrollTo scrolls to the specified item. func (l *LayoutList) ScrollTo(n int) { l.Position.First = n l.Position.Offset = 0 l.Position.BeforeEnd = true }