diff --git a/docs/ui/migration.md b/docs/ui/migration.md index f68eb5a0d..d56d96d8e 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -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 diff --git a/internal/uimigrate/uiapi.go b/internal/uimigrate/uiapi.go index 3924f7db3..66ffe6292 100644 --- a/internal/uimigrate/uiapi.go +++ b/internal/uimigrate/uiapi.go @@ -396,6 +396,7 @@ var elementChains = map[string]bool{ "Tooltip": true, "Top": true, "TopPercent": true, + "TrackLayoutWidth": true, "TrackLines": true, "TrackScroll": true, "Transform": true, diff --git a/ui/api_gen.go b/ui/api_gen.go index b9a04e139..f981d744e 100644 --- a/ui/api_gen.go +++ b/ui/api_gen.go @@ -8021,6 +8021,19 @@ func (_handle Element) TopPercent(p float32) Element { return wrapElement(_node.TopPercent(p)) } +// 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 (_handle Element) TrackLayoutWidth() Element { + _node := _handle.node() + if _node == nil { + return Element{} + } + _ctx := _node.c + _ = _ctx + return wrapElement(_node.TrackLayoutWidth()) +} + // TrackLines keeps in l where the text area lays out its lines. func (_handle Element) TrackLines(l *TextLines) Element { _node := _handle.node() diff --git a/ui/element.go b/ui/element.go index 4c2961a3e..329ac3d34 100644 --- a/ui/element.go +++ b/ui/element.go @@ -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 diff --git a/ui/layout.go b/ui/layout.go index cedabc309..51ec047f3 100644 --- a/ui/layout.go +++ b/ui/layout.go @@ -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 { diff --git a/ui/layout_width.go b/ui/layout_width.go new file mode 100644 index 000000000..e99fdbf61 --- /dev/null +++ b/ui/layout_width.go @@ -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) +} diff --git a/ui/layout_width_test.go b/ui/layout_width_test.go new file mode 100644 index 000000000..763040a20 --- /dev/null +++ b/ui/layout_width_test.go @@ -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") + } + }) + } +} diff --git a/ui/layoutcache.go b/ui/layoutcache.go index 3d91a652d..4a4497c1f 100644 --- a/ui/layoutcache.go +++ b/ui/layoutcache.go @@ -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 @@ -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...) } diff --git a/ui/layoutcache_test.go b/ui/layoutcache_test.go index abc106b4b..74d5110b2 100644 --- a/ui/layoutcache_test.go +++ b/ui/layoutcache_test.go @@ -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.