Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/ui/migration.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
# Migrating to checked elements

## Committed layout widths of clipped boxes

`Element.TrackLayoutWidth()` opts a non-scrolling box into
`CommittedSpace.LayoutWidth() (clientWidth, contentWidth float32, ok bool)`.
Query through a bound `Handle` after the final layout, for example in
`Context.AfterFrame`. Values are local logical DIPs, not transformed window
coordinates: both include padding and exclude borders. Content width is at
least client width.

```go
ui.Row(c).Width(180).Clip().TrackLayoutWidth().Bind(&strip).Children(pills)
c.AfterFrame(func() {
if space, ok := strip.Coordinates(c); ok {
if client, content, ok := space.LayoutWidth(); ok {
setOverflow(content > client+1)
}
}
})
```

The extent combines the box's own finite-layout flow allocation with its
direct children's final border boxes and inline-end margins, followed by the
container's inline-end padding. Relative and absolute placement are included;
hidden and leaving children are not. Right-to-left layout measures toward the
left. Transforms, painted ink and overflow escaping grandchildren are excluded.
This bounded layout metric is **not** a general recursive DOM `scrollWidth`.
For a nowrap pills strip, expose each pill's nonshrinking width on a direct
child box, including any button inside it; do not rely on an overflowing
grandchild to enlarge that box. Keep sibling controls outside the measured
strip, as in the web layout.

Tracking does not enable scrolling, change finite clipping constraints, or
change existing scroll metrics. Untracked boxes, scrollers, non-box elements,
hidden elements and uncommitted measurement probes report false. Geometry
expires at the next build or window close, as with `CommittedSpace.Bounds`.
Existing callers need no migration; apply tolerances such as `+1` in the app.

## Opt-in child-first arrow navigation

`Element.HandleNavigationKeys(keys, fn)` adds an optional nearest-owner route
Expand Down
1 change: 1 addition & 0 deletions internal/uimigrate/uiapi.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions ui/api_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 16 additions & 15 deletions ui/element.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,21 +338,22 @@ type node struct {

fitting *fittingBuilder

epoch uint64
key any
valueInput func(*node)
c *context
id uint64
flags uint32
pointerEvents int8
committed bool
parent *node
first *node
last *node
next *node
nchild int
depth int
st *state
epoch uint64
key any
valueInput func(*node)
c *context
id uint64
flags uint32
pointerEvents int8
committed bool
trackLayoutWidth bool
parent *node
first *node
last *node
next *node
nchild int
depth int
st *state
// track is the ScrollState of a scroll container (TrackScroll).
track *ScrollState

Expand Down
3 changes: 3 additions & 0 deletions ui/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,9 @@ func computeBox(e *node, w, h float32) {
e.contentH = float64(max(uh, ch) + e.padY())
}
layoutAbsolute(e)
if e.trackLayoutWidth && !e.scrolls() {
e.keepLayoutWidth(uw, cw)
}
}

type flexItem struct {
Expand Down
45 changes: 45 additions & 0 deletions ui/layout_width.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package ui

// TrackLayoutWidth records a non-scrolling box's horizontal layout extent for
// CommittedSpace.LayoutWidth. It does not change layout constraints or clipping.
// Call it on every build whose committed width will be queried.
func (e *node) TrackLayoutWidth() *node {
e.trackLayoutWidth = true
return e
}

// LayoutWidth returns a tracked box's client and content widths in local DIPs.
// Both include padding and exclude borders. Content is at least client and
// includes the box's own laid-out flow span and its final direct children's
// border boxes and margins toward inline-end, followed by that end's padding.
// Hidden and leaving children are excluded. Right-to-left layout measures
// toward the left. Transforms, painted ink and overflowing grandchildren are
// not included: this is not a recursive DOM scrollWidth query.
//
// Like Bounds, this reads only the final committed frame on the UI thread.
// Untracked, scrolling, non-box, hidden, expired and uncommitted elements report
// false, including measurement probes. No scrolling is enabled or changed.
func (g CommittedSpace) LayoutWidth() (clientWidth, contentWidth float32, ok bool) {
n, ok := g.node()
if !ok || !n.trackLayoutWidth || n.kind != kindBox || n.scrolls() {
return 0, 0, false
}
borders := n.border[1] + n.border[3]
clientWidth = max(n.w-borders, 0)
return clientWidth, max(clientWidth, float32(n.contentW)-borders), true
}

func (e *node) keepLayoutWidth(flow, client float32) {
width := max(flow, client) + e.padX()
for c := e.first; c != nil; c = c.next {
if c.hidden || c.leaving != 0 {
continue
}
end := c.x + c.w + max(c.m(1), 0) + e.pad[1] + e.border[1]
if e.rtl() {
end = e.w - c.x + max(c.m(3), 0) + e.pad[3] + e.border[3]
}
width = max(width, end)
}
e.contentW = float64(width)
}
94 changes: 94 additions & 0 deletions ui/layout_width_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package ui

import "testing"

func TestCommittedLayoutWidth(t *testing.T) {
cases := []struct {
name string
build func(*Context, Element)
content float32
}{
{"empty", func(c *Context, e Element) {}, 96},
{"nowrap", func(c *Context, e Element) {
e.Children(func() {
for range 3 {
Box(c).Size(60, 20).Shrink(0)
}
})
}, 210},
{"wrap", func(c *Context, e Element) {
e.Wrap().Children(func() {
for range 3 {
Box(c).Size(60, 20).Shrink(0)
}
})
}, 96},
{"relative", func(c *Context, e Element) {
e.Children(func() { Box(c).Size(60, 20).Shrink(0).Left(100) })
}, 180},
{"absolute", func(c *Context, e Element) {
e.Children(func() { Box(c).Size(20, 20).Absolute().Left(160) })
}, 190},
{"negative end margin", func(c *Context, e Element) {
e.Children(func() { Box(c).Size(200, 20).Shrink(0).Margin(0, -150, 0, 0) })
}, 220},
{"rtl", func(c *Context, e Element) {
e.Direction(RightToLeft).Children(func() {
for range 3 {
Box(c).Size(60, 20).Shrink(0)
}
})
}, 210},
{"hidden child", func(c *Context, e Element) {
e.Children(func() {
Box(c).Size(60, 20).Shrink(0)
Box(c).Size(500, 20).Shrink(0).Hidden(true)
})
}, 96},
{"direct layout only", func(c *Context, e Element) {
e.Children(func() {
Box(c).Size(40, 20).Shrink(0).Children(func() {
Box(c).Size(400, 20).Shrink(0).Translate(300, 0)
})
})
}, 96},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
var h Handle
var space CommittedSpace
tracked := false
tt := NewTester(func(c *Context) {
e := Row(c).Size(100, 120).Padding(0, 10).BorderWidth(0, 2).Gap(5).Clip().Bind(&h)
if tracked {
e.TrackLayoutWidth()
}
test.build(c, e)
c.AfterFrame(func() {
var ok bool
space, ok = h.Coordinates(c)
if !ok {
t.Fatal("missing committed box")
}
})
}, 300, 200)
defer tt.rt.close()
if _, _, ok := space.LayoutWidth(); ok {
t.Fatal("untracked box published layout width")
}
tracked = true
for range 2 {
tt.Frame()
client, content, ok := space.LayoutWidth()
if !ok || client != 96 || content != test.content {
t.Fatalf("layout width = %v, %v, %v; want 96, %v, true", client, content, ok, test.content)
}
}
tracked = false
tt.Frame()
if _, _, ok := space.LayoutWidth(); ok {
t.Fatal("untracked rebuild retained layout width")
}
})
}
}
23 changes: 13 additions & 10 deletions ui/layoutcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,17 @@ const textLayoutSet = setFamily | setSize | setWeight | setItalic | setLineHeigh
type layoutRecord struct {
// layout is engine.layouts of the last layout that saw the element,
// and ok tells that what follows, to parent, is what it saw then.
layout uint64
ok bool
style layoutStyle
flags uint32
hidden bool
ts textLayout
cols, rows []Track
spans string
media [2]float32
placeOn uint64
layout uint64
ok bool
style layoutStyle
flags uint32
hidden bool
trackLayoutWidth bool
ts textLayout
cols, rows []Track
spans string
media [2]float32
placeOn uint64
// parent is the ID of its parent, index its place among its siblings
// and nchild how many children it had.
parent uint64
Expand Down Expand Up @@ -153,12 +154,14 @@ func sameLayout(e *node, parent uint64, index int, changed bool) bool {
r.ts = layoutOfText(&e.ts)
}
equal := fresh && r.style == e.layoutStyle && r.flags == e.flags&layoutFlags && r.hidden == e.hidden &&
r.trackLayoutWidth == e.trackLayoutWidth &&
r.spans == spans && r.media == media && r.placeOn == placeOn &&
r.parent == parent && r.index == int32(index) && r.nchild == int32(e.nchild) &&
slices.Equal(r.cols, e.cols) && slices.Equal(r.rows, e.rows)
if !equal {
r.ok = true
r.style, r.flags, r.hidden = e.layoutStyle, e.flags&layoutFlags, e.hidden
r.trackLayoutWidth = e.trackLayoutWidth
if !slices.Equal(r.cols, e.cols) || !slices.Equal(r.rows, e.rows) {
r.cols, r.rows = append(r.cols[:0], e.cols...), append(r.rows[:0], e.rows...)
}
Expand Down
2 changes: 1 addition & 1 deletion ui/layoutcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ func TestLayoutCacheReuses(t *testing.T) {
func TestLayoutInputs(t *testing.T) {
// Fields of node that the layout reads besides layoutStyle, compared
// or made volatile by sameLayout, and those it writes.
read := "flags ts cols rows spans image svg placeOn list form colFit overflow gridFit leaving ghosts attach popover " +
read := "flags trackLayoutWidth ts cols rows spans image svg placeOn list form colFit overflow gridFit leaving ghosts attach popover " +
"first last next nchild parent st c measured tl lay laid x y w h contentW contentH frags runes paragraph " +
"ownText merged spansKey collapsed rowMinW field hidden placed flips originAttached originX originY originSet edgeMargin edgeMarginSet fitting popoverHandle collisionPadding matchAnchorWidth"
// Fields the layout never reads.
Expand Down
Loading