1// Package layout computes logical-pixel boxes and an ordered, serializable paint list.
2// Input nodes and CSS styles are borrowed for one synchronous Layout call.
3package layout
4
5import (
6 "errors"
7 "image"
8 "math"
9 "slices"
10
11 "github.com/bnema/nefergui/internal/css"
12 "github.com/bnema/nefergui/internal/text"
13 "github.com/go-text/typesetting/di"
14)
15
16// Kind selects intrinsic and container behavior. CSS display:none suppresses all kinds;
17// display:flex takes precedence over Kind, and row/column flex defaults are overridable.
18type Kind string
19
20const (
21 Box Kind = "box"
22 Row Kind = "row"
23 Column Kind = "column"
24 Stack Kind = "stack"
25 Scroll Kind = "scroll"
26 Text Kind = "text"
27 Image Kind = "image"
28)
29const limit = 1 << 24
30
31// Rect uses logical pixels, including fractional positions.
32type Rect struct{ X, Y, W, H float64 }
33
34func (r Rect) Contains(x, y float64) bool { return x >= r.X && y >= r.Y && x < r.X+r.W && y < r.Y+r.H }
35func (r Rect) intersect(q Rect) Rect {
36 x := math.Max(r.X, q.X)
37 y := math.Max(r.Y, q.Y)
38 return Rect{x, y, math.Max(0, math.Min(r.X+r.W, q.X+q.W)-x), math.Max(0, math.Min(r.Y+r.H, q.Y+q.H)-y)}
39}
40func safe(v float64) float64 {
41 if math.IsNaN(v) || v < 0 {
42 return 0
43 }
44 if math.IsInf(v, 0) || v > limit {
45 return limit
46 }
47 return v
48}
49func coord(v float64) float64 {
50 if math.IsNaN(v) {
51 return 0
52 }
53 if math.IsInf(v, 1) {
54 return limit
55 }
56 if math.IsInf(v, -1) {
57 return -limit
58 }
59 return math.Max(-limit, math.Min(limit, v))
60}
61func length(l css.Length, basis float64) (float64, bool) {
62 switch l.Unit {
63 case "px":
64 return coord(l.Value), true
65 case "%":
66 return coord(safe(basis) * l.Value / 100), true
67 }
68 return 0, false
69}
70func positive(l css.Length, basis float64) float64 { v, _ := length(l, basis); return safe(v) }
71func edges(s css.LengthSides, basis float64) Edges {
72 return Edges{coordValue(s.Top, basis), coordValue(s.Right, basis), coordValue(s.Bottom, basis), coordValue(s.Left, basis)}
73}
74func coordValue(l css.Length, basis float64) float64 { v, _ := length(l, basis); return v }
75
76// Edges are in top/right/bottom/left order.
77type Edges struct{ Top, Right, Bottom, Left float64 }
78
79func (e Edges) horizontal() float64 { return e.Left + e.Right }
80func (e Edges) vertical() float64 { return e.Top + e.Bottom }
81func nonnegative(e Edges) Edges {
82 return Edges{safe(e.Top), safe(e.Right), safe(e.Bottom), safe(e.Left)}
83}
84
85// Measure receives the available content width (zero means unbounded), and must
86// return finite logical dimensions and optionally shaped lines. It may return an error.
87// TextEngine, when supplied, is used for Text nodes without an explicit callback.
88type Measure func(width float64) (Size, []text.Line, error)
89type Size struct{ W, H float64 }
90
91// Node has no parent pointers or renderer resources. ID identifies hit targets and
92// display commands; callers must supply stable, unique IDs if they need identity.
93// ImageSize is the decoded image's intrinsic size; image ownership remains with caller.
94// Image is borrowed through painting. Treat it as immutable while passed to Image:
95// pass a new image value to change pixels. Renderer cache identity requires a
96// comparable image.Image interface value (for example, *image.RGBA).
97type Node struct {
98 ID string
99 Kind Kind
100 Style *css.Style
101 Children []*Node
102 Content string
103 ImageSize Size
104 Image image.Image `json:"-"`
105 Measure Measure `json:"-"`
106 ScrollX, ScrollY float64
107 NoWrap bool // measure text at unbounded width; useful for single-line editors
108 EditorScroll bool // clip and clamp own text on both axes, without child scrolling
109 // Rect, when HasRect is set on a direct child of a Stack, is the child's
110 // border-box geometry relative to the Stack's content box. It replaces the
111 // child's authored width, height and margin; it is ignored elsewhere.
112 // A flex-styled Stack with a visible Rect child is rejected.
113 Rect Rect
114 HasRect bool
115}
116
117// Result is a detached layout snapshot. Style is deliberately not retained.
118type Result struct {
119 ID string
120 Kind Kind
121 Border, Content, Clip Rect
122 Margin, Padding, BorderWidths Edges
123 ContentSize Size
124 ScrollX, ScrollY float64
125 Lines []text.Line `json:"-"`
126 Children []*Result
127 Clipped bool
128}
129
130// Command is a paint operation. Text line ranges refer to rune offsets
131// in Text; image pixels are borrowed and excluded from serialized display lists.
132type Command struct {
133 Op string
134 ID string
135 Rect Rect
136 Color css.Color
137 Opacity float64
138 Widths Edges
139 Colors css.ColorSides
140 Radii [4]float64
141 Shadow *css.Shadow `json:",omitempty"`
142 Text string `json:",omitempty"`
143 Runs []Run `json:",omitempty"`
144 Image image.Image `json:"-"`
145}
146type Run struct {
147 Start, End int
148 X, Y, Advance float64
149 // Face is borrowed for in-process rasterization. FaceID and Size preserve
150 // the shaping identity and logical pixel size in serialized display lists.
151 Face *text.Face `json:"-"`
152 FaceID string
153 Size float64
154 Glyphs []Glyph
155}
156type Glyph struct {
157 ID uint32
158 X, Y, Advance float64
159 Cluster int
160}
161
162// Output contains a JSON-safe tree and paint list in back-to-front command order.
163type Output struct {
164 Tree *Result
165 Display []Command
166}
167
168// Options provide logical viewport size and an optional text shaping engine.
169// Arena, when set, supplies reusable storage for the Output.
170type Options struct {
171 Width, Height float64
172 TextEngine *text.Engine
173 Arena *Arena
174}
175
176// Arena holds the result tree and display storage of one Output, so a steady
177// layout reuses it instead of allocating. An Output stays valid until its
178// arena is passed to Layout again; alternate two arenas to keep the previous
179// Output readable while building the next.
180type Arena struct {
181 results slab[Result]
182 children slab[*Result]
183 commands []Command
184 kept int // length of the last list passed to Keep, until the next Keep
185 // Flex scratch: valid during one Layout call only.
186 flexItems slab[flexItem]
187 flexEnds slab[int]
188 shadows slab[css.Shadow]
189 runs slab[Run]
190 glyphs slab[Glyph]
191}
192
193// Keep makes display, the Output.Display of the arena's last Layout after the
194// caller appended to it, the list the arena recycles next. Without it, a list
195// that outgrew its arena block is reallocated by the caller every frame.
196func (a *Arena) Keep(display []Command) {
197 if cap(display) > cap(a.commands) {
198 a.commands = display[:0]
199 }
200 a.kept = len(display)
201}
202
203var ErrDepth = errors.New("layout: tree depth exceeds 256")
204
205// Layout does not mutate inputs. The caller must not create cycles in the node tree.
206func Layout(root *Node, options Options) (Output, error) {
207 if root == nil {
208 return Output{}, nil
209 }
210 ctx := newContext(options)
211 viewport := Rect{0, 0, safe(options.Width), safe(options.Height)}
212 tree, err := ctx.place(root, viewport.X, viewport.Y, viewport.W, viewport.H, nil, nil, 0)
213 if err != nil {
214 return Output{}, err
215 }
216 out := Output{Tree: tree}
217 out.Display = ctx.display(root, tree)
218 return out, nil
219}
220
221// Natural returns the border-box size root takes when it is not stretched: the
222// widest and tallest envelope of its content plus its padding and border,
223// honoring explicit width, height, min and max like Layout. Text wraps at
224// options.Width minus the root margins, but the width may still exceed
225// options.Width when content cannot wrap (a long word, an unwrapped text, an
226// explicit width): callers clamp. The root's own margins are not part of the
227// result. Percentage height, min-height and max-height of root are treated as
228// auto, as for nested nodes (CSS: the containing height is indefinite), so
229// options.Height has no effect.
230func Natural(root *Node, options Options) (Size, error) {
231 if root == nil {
232 return Size{}, nil
233 }
234 ctx := newContext(options)
235 s := style(root)
236 if s.Display == css.KeywordNone {
237 return Size{}, nil
238 }
239 if err := validateStackRects(root, s); err != nil {
240 return Size{}, err
241 }
242 s = IndefiniteHeights(s)
243 availableW, availableH := safe(options.Width), safe(options.Height)
244 margin := edges(s.Margin, availableW)
245 padding := nonnegative(edges(s.Padding, availableW))
246 border := nonnegative(edges(s.BorderWidth, availableW))
247 if s.BorderStyle.Top != css.KeywordSolid {
248 border.Top = 0
249 }
250 if s.BorderStyle.Right != css.KeywordSolid {
251 border.Right = 0
252 }
253 if s.BorderStyle.Bottom != css.KeywordSolid {
254 border.Bottom = 0
255 }
256 if s.BorderStyle.Left != css.KeywordSolid {
257 border.Left = 0
258 }
259 insetW := padding.horizontal() + border.horizontal()
260 insetH := padding.vertical() + border.vertical()
261 borderBox := s.BoxSizing == css.KeywordBorderBox
262 w, explicitW := length(s.Width, availableW)
263 h, explicitH := length(s.Height, availableH)
264 if explicitW {
265 w = bounded(w, s.MinWidth, s.MaxWidth, availableW)
266 if !borderBox {
267 w += insetW
268 }
269 }
270 if explicitH {
271 h = bounded(h, s.MinHeight, s.MaxHeight, availableH)
272 if !borderBox {
273 h += insetH
274 }
275 }
276 contentW := safe(availableW - margin.horizontal() - insetW)
277 if explicitW {
278 contentW = safe(w - insetW)
279 }
280 measureW := contentW
281 if root.Kind == Text && root.NoWrap {
282 measureW = 0
283 }
284 measured, _, err := ctx.intrinsicDepth(root, measureW, 1)
285 if err != nil {
286 return Size{}, err
287 }
288 if !explicitW {
289 w = boxBound(measured.W+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox)
290 }
291 if !explicitH {
292 h = boxBound(measured.H+insetH, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
293 }
294 return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil
295}
296
297// IndefiniteHeights returns s with percentage height, min-height and max-height
298// replaced by auto (no constraint), for a root whose containing height is not
299// known, such as a surface that does not exist yet.
300func IndefiniteHeights(s css.Style) css.Style {
301 if s.Height.Unit == "%" {
302 s.Height = css.Length{Unit: "auto"}
303 }
304 if s.MinHeight.Unit == "%" {
305 s.MinHeight = css.Length{}
306 }
307 if s.MaxHeight.Unit == "%" {
308 s.MaxHeight = css.Length{}
309 }
310 return s
311}
312
313// display paints the placed tree into a list sized once from the tree.
314func (c context) display(root *Node, tree *Result) []Command {
315 n := countCommands(root, tree)
316 a := c.arena
317 // Size by what the list held last time, so caller decorations do not make
318 // a kept list look oversized and get dropped every frame.
319 need := max(n.commands, a.kept)
320 if oversized(cap(a.commands), need) {
321 a.commands = nil // do not pin one huge frame's list
322 } else {
323 clear(a.commands[:cap(a.commands)]) // callers may have appended in place
324 }
325 a.shadows.reset(n.shadows)
326 a.runs.reset(n.runs)
327 a.glyphs.reset(n.glyphs)
328 if n.commands == 0 {
329 return nil
330 }
331 out := slices.Grow(a.commands[:0], n.commands)
332 c.paint(root, tree, &out)
333 a.commands = out
334 return out
335}
336
337type context struct {
338 options Options
339 arena *Arena // storage for results and display; never nil
340}
341
342// newContext recycles options.Arena, or uses a fresh one.
343func newContext(options Options) context {
344 arena := options.Arena
345 if arena == nil {
346 arena = new(Arena)
347 }
348 // The tree size is only known after placing it: size by the last demand.
349 arena.results.reset(arena.results.need)
350 arena.children.reset(arena.children.need)
351 arena.flexItems.reset(arena.flexItems.need)
352 arena.flexEnds.reset(arena.flexEnds.need)
353 return context{options: options, arena: arena}
354}
355
356func style(n *Node) css.Style {
357 if n.Style != nil {
358 return *n.Style
359 }
360 return css.Style{Display: css.KeywordBlock, OverflowX: css.KeywordVisible, OverflowY: css.KeywordVisible, FlexShrink: 1}
361}
362func validateStackRects(n *Node, s css.Style) error {
363 if n.Kind == Stack && s.Display == css.KeywordFlex {
364 for _, child := range n.Children {
365 if child != nil && child.HasRect && style(child).Display != css.KeywordNone {
366 return errors.New("layout: Rect is unsupported in a flex-styled Stack")
367 }
368 }
369 }
370 return nil
371}
372func bounded(v float64, minL, maxL css.Length, basis float64) float64 {
373 v = safe(v)
374 if min, ok := length(minL, basis); ok {
375 v = math.Max(v, safe(min))
376 }
377 if max, ok := length(maxL, basis); ok {
378 v = math.Min(v, safe(max))
379 }
380 return safe(v)
381}
382
383// boxBound applies constraints to the CSS sizing space, not to the outer box.
384func boxBound(border float64, minL, maxL css.Length, basis, insets float64, borderBox bool) float64 {
385 if borderBox {
386 return math.Max(insets, bounded(border, minL, maxL, basis))
387 }
388 return safe(bounded(safe(border-insets), minL, maxL, basis) + insets)
389}
390func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) {
391 return c.intrinsicDepth(n, w, 0)
392}
393
394func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Line, error) {
395 if depth > 256 {
396 return Size{}, nil, ErrDepth
397 }
398 st := style(n)
399 if err := validateStackRects(n, st); err != nil {
400 return Size{}, nil, err
401 }
402 if n.Measure != nil {
403 s, lines, err := n.Measure(w)
404 if err != nil {
405 return Size{}, nil, err
406 }
407 for _, line := range lines {
408 if math.IsNaN(line.Width) || math.IsInf(line.Width, 0) || math.IsNaN(line.Height) || math.IsInf(line.Height, 0) {
409 return Size{}, nil, errors.New("layout: invalid measured text")
410 }
411 }
412 return Size{safe(s.W), safe(s.H)}, lines, nil
413 }
414 if n.Kind == Image {
415 return Size{safe(n.ImageSize.W), safe(n.ImageSize.H)}, nil, nil
416 }
417 if n.Kind == Text && c.options.TextEngine != nil {
418 st := style(n)
419 req := text.Request{Families: st.FontFamily, Weight: float32(st.FontWeight), Italic: st.FontStyle == css.KeywordItalic || st.FontStyle == css.KeywordOblique, Size: safe(st.FontSize.Value), LetterSpacing: coordValue(st.LetterSpacing, 0)}
420 if req.Size == 0 {
421 req.Size = 16
422 }
423 content := n.Content
424 if content == "" && n.EditorScroll {
425 // Preserve one font-metric line for empty editors, caret geometry
426 // and IME composition without painting a visible glyph.
427 content = " "
428 }
429 l, err := c.options.TextEngine.Measure(content, req, w)
430 if err != nil {
431 return Size{}, nil, err
432 }
433 lineHeight := l.LineHeight
434 switch st.LineHeightKind {
435 case css.KeywordNumber:
436 lineHeight = safe(req.Size * st.LineHeightNumber)
437 case css.KeywordNormal:
438 default:
439 if st.LineHeight.Unit != "auto" && st.LineHeight.Unit != "" {
440 lineHeight = positive(st.LineHeight, req.Size)
441 }
442 }
443 // Keep the glyphs' shaped positions; adjust baseline and line origins, not outlines.
444 if lineHeight != l.LineHeight && len(l.Lines) > 0 {
445 l = l.Clone() // Measure results are shared with the engine cache
446 for i := range l.Lines {
447 line := &l.Lines[i]
448 dy := float64(i)*(lineHeight-l.LineHeight) + (lineHeight-l.LineHeight)/2
449 line.Baseline += dy
450 line.Height = lineHeight
451 for j := range line.Runs {
452 run := &line.Runs[j]
453 run.Y += dy
454 for k := range run.Glyphs {
455 run.Glyphs[k].Y += dy
456 }
457 }
458 }
459 l.Height = float64(len(l.Lines)) * lineHeight
460 }
461 return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil
462 }
463 if depth == 0 {
464 return Size{}, nil, nil
465 }
466 // Containers contribute their children to auto flex sizing. Measuring them
467 // as zero compresses nested columns and makes empty editors collapse.
468 row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn
469 gap := positive(st.RowGap, w)
470 if row {
471 gap = positive(st.ColumnGap, w)
472 }
473 var size Size
474 count := 0
475 for _, child := range n.Children {
476 if child == nil || style(child).Display == css.KeywordNone {
477 continue
478 }
479 if n.Kind == Stack && child.HasRect {
480 size.W = math.Max(size.W, safe(child.Rect.X+child.Rect.W))
481 size.H = math.Max(size.H, safe(child.Rect.Y+child.Rect.H))
482 count++
483 continue
484 }
485 cs := style(child)
486 inW, inH := flexInsets(cs, w)
487 measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1)
488 if err != nil {
489 return Size{}, nil, err
490 }
491 cw, ch := measured.W+inW, measured.H+inH
492 if value, ok := length(cs.Width, w); ok && cs.Width.Unit != "%" {
493 cw = value
494 if cs.BoxSizing != css.KeywordBorderBox {
495 cw += inW
496 }
497 }
498 // Percent heights need a definite containing height, unavailable here.
499 if cs.Height.Unit != "%" {
500 if value, ok := length(cs.Height, 0); ok {
501 ch = value
502 if cs.BoxSizing != css.KeywordBorderBox {
503 ch += inH
504 }
505 }
506 }
507 cw = boxBound(cw, cs.MinWidth, cs.MaxWidth, w, inW, cs.BoxSizing == css.KeywordBorderBox)
508 ch = boxBound(ch, cs.MinHeight, cs.MaxHeight, 0, inH, cs.BoxSizing == css.KeywordBorderBox)
509 // Natural measurement is an unwrapped envelope; actual placement
510 // distributes flex-basis and wraps against the definite container size.
511 margin := edges(cs.Margin, w)
512 cw += margin.horizontal()
513 ch += margin.vertical()
514 if row {
515 if count > 0 {
516 size.W += gap
517 }
518 size.W += cw
519 size.H = math.Max(size.H, ch)
520 } else if n.Kind == Stack {
521 size.W = math.Max(size.W, cw)
522 size.H = math.Max(size.H, ch)
523 } else {
524 if count > 0 && st.Display == css.KeywordFlex {
525 size.H += gap
526 }
527 size.W = math.Max(size.W, cw)
528 size.H += ch
529 }
530 count++
531 }
532 return size, nil, nil
533}
534func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) {
535 if depth > 256 {
536 return nil, ErrDepth
537 }
538 if n == nil {
539 return nil, nil
540 }
541 s := style(n)
542 if s.Display == css.KeywordNone {
543 return nil, nil
544 }
545 if err := validateStackRects(n, s); err != nil {
546 return nil, err
547 }
548 availableW = safe(availableW)
549 availableH = safe(availableH)
550 margin := edges(s.Margin, availableW)
551 padding := nonnegative(edges(s.Padding, availableW))
552 border := nonnegative(edges(s.BorderWidth, availableW))
553 if s.BorderStyle.Top != css.KeywordSolid {
554 border.Top = 0
555 }
556 if s.BorderStyle.Right != css.KeywordSolid {
557 border.Right = 0
558 }
559 if s.BorderStyle.Bottom != css.KeywordSolid {
560 border.Bottom = 0
561 }
562 if s.BorderStyle.Left != css.KeywordSolid {
563 border.Left = 0
564 }
565 outerW := safe(availableW - margin.horizontal())
566 insetW := padding.horizontal() + border.horizontal()
567 insetH := padding.vertical() + border.vertical()
568 w, explicitW := length(s.Width, availableW)
569 h, explicitH := length(s.Height, availableH)
570 borderBox := s.BoxSizing == css.KeywordBorderBox
571 // Forced flex lengths are border-box; authored dimensions and constraints use box-sizing.
572 if forcedW != nil {
573 w, explicitW = *forcedW, true
574 } else if explicitW {
575 w = bounded(w, s.MinWidth, s.MaxWidth, availableW)
576 if !borderBox {
577 w += insetW
578 }
579 }
580 if forcedH != nil {
581 h, explicitH = *forcedH, true
582 } else if explicitH {
583 h = bounded(h, s.MinHeight, s.MaxHeight, availableH)
584 if !borderBox {
585 h += insetH
586 }
587 }
588 if !explicitW {
589 w = outerW
590 if n.Kind == Image {
591 w = 0
592 }
593 }
594 if !explicitW && n.Kind != Image {
595 w = boxBound(w, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox)
596 }
597 w = safe(math.Max(w, insetW))
598 contentW := safe(w - insetW)
599 measureW := contentW
600 if n.Kind == Text && n.NoWrap {
601 measureW = 0
602 }
603 measured, lines, err := c.intrinsic(n, measureW)
604 if err != nil {
605 return nil, err
606 }
607 if !explicitW && n.Kind == Image {
608 w = boxBound(measured.W+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox)
609 contentW = safe(w - insetW)
610 measured, lines, err = c.intrinsic(n, contentW)
611 if err != nil {
612 return nil, err
613 }
614 }
615 if !explicitH {
616 h = measured.H + insetH
617 if n.Kind == Image && measured.W > 0 && explicitW {
618 h = measured.H*contentW/measured.W + insetH
619 }
620 }
621 if n.Kind == Image && !explicitW && explicitH && measured.H > 0 {
622 w = boxBound(measured.W*safe(h-insetH)/measured.H+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox)
623 contentW = safe(w - insetW)
624 }
625 h = safe(math.Max(h, insetH))
626 if !explicitH {
627 h = boxBound(h, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
628 }
629 r := c.arena.results.one(Result{ID: n.ID, Kind: n.Kind, Border: Rect{coord(x + margin.Left), coord(y + margin.Top), w, h}, Margin: margin, Padding: padding, BorderWidths: border, Lines: lines})
630 r.Content = Rect{coord(r.Border.X + border.Left + padding.Left), coord(r.Border.Y + border.Top + padding.Top), contentW, safe(h - insetH)}
631 r.ContentSize = Size{measured.W, measured.H}
632 // Block and stack auto heights depend on their children's envelopes.
633 flex := s.Display == css.KeywordFlex
634 if flex {
635 // An auto-height flex container needs its children's natural height
636 // before distributing space. Otherwise every child is shrunk into zero.
637 // This repeats subtree measurement for nested auto-height containers;
638 // a node/width map cache cost more time and memory in our benchmarks.
639 if !explicitH {
640 natural, _, measureErr := c.intrinsicDepth(n, contentW, 1)
641 if measureErr != nil {
642 return nil, measureErr
643 }
644 r.Border.H = boxBound(math.Max(natural.H+insetH, h), s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
645 r.Content.H = safe(r.Border.H - insetH)
646 }
647 err = c.flex(n, r, s, depth)
648 } else {
649 err = c.children(n, r, s, depth)
650 }
651 if err != nil {
652 return nil, err
653 }
654 if !explicitH {
655 r.Border.H = boxBound(math.Max(safe(h-insetH), r.ContentSize.H)+insetH, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
656 r.Content.H = safe(r.Border.H - insetH)
657 }
658 clipX := overflowClips(s.OverflowX) || n.Kind == Scroll || n.EditorScroll
659 clipY := overflowClips(s.OverflowY) || n.Kind == Scroll || n.EditorScroll
660 if clipX || clipY {
661 r.Clipped = true
662 r.Clip = r.Content
663 if !clipX {
664 r.Clip.X = -limit
665 r.Clip.W = 2 * limit
666 }
667 if !clipY {
668 r.Clip.Y = -limit
669 r.Clip.H = 2 * limit
670 }
671 if clipX && (s.OverflowX == css.KeywordScroll || s.OverflowX == css.KeywordAuto || n.Kind == Scroll || n.EditorScroll) {
672 r.ScrollX = math.Min(safe(n.ScrollX), safe(r.ContentSize.W-r.Content.W))
673 }
674 if clipY && (s.OverflowY == css.KeywordScroll || s.OverflowY == css.KeywordAuto || n.Kind == Scroll || n.EditorScroll) {
675 r.ScrollY = math.Min(safe(n.ScrollY), safe(r.ContentSize.H-r.Content.H))
676 }
677 for _, child := range r.Children {
678 shift(child, -r.ScrollX, -r.ScrollY)
679 }
680 }
681 return r, nil
682}
683func overflowClips(k css.Keyword) bool {
684 return k == css.KeywordHidden || k == css.KeywordScroll || k == css.KeywordAuto
685}
686func shift(r *Result, dx, dy float64) {
687 r.Border.X = coord(r.Border.X + dx)
688 r.Border.Y = coord(r.Border.Y + dy)
689 r.Content.X = coord(r.Content.X + dx)
690 r.Content.Y = coord(r.Content.Y + dy)
691 r.Clip.X = coord(r.Clip.X + dx)
692 r.Clip.Y = coord(r.Clip.Y + dy)
693 for _, child := range r.Children {
694 shift(child, dx, dy)
695 }
696}
697func envelope(r *Result) (float64, float64) {
698 return safe(r.Border.W + r.Margin.horizontal()), safe(r.Border.H + r.Margin.vertical())
699}
700func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
701 y := r.Content.Y
702 for _, ch := range n.Children {
703 if ch == nil {
704 continue
705 }
706 var child *Result
707 var err error
708 absolute := n.Kind == Stack && ch.HasRect
709 if absolute {
710 // Direct geometry: forced border-box size, and the origin is offset
711 // by the resolved margin so the border box lands exactly at Rect.
712 w, h := safe(ch.Rect.W), safe(ch.Rect.H)
713 m := edges(style(ch).Margin, r.Content.W)
714 child, err = c.place(ch, coord(r.Content.X+ch.Rect.X-m.Left), coord(r.Content.Y+ch.Rect.Y-m.Top), r.Content.W, r.Content.H, &w, &h, depth+1)
715 } else {
716 child, err = c.place(ch, r.Content.X, y, r.Content.W, r.Content.H, nil, nil, depth+1)
717 }
718 if err != nil {
719 return err
720 }
721 if child == nil {
722 continue
723 }
724 cw, chh := envelope(child)
725 if absolute {
726 cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)
727 }
728 r.Children = c.appendChild(r.Children, child, len(n.Children))
729 r.ContentSize.W = math.Max(r.ContentSize.W, cw)
730 if n.Kind == Stack {
731 r.ContentSize.H = math.Max(r.ContentSize.H, chh)
732 } else {
733 y = coord(y + chh)
734 r.ContentSize.H = math.Max(r.ContentSize.H, y-r.Content.Y)
735 }
736 }
737 return nil
738}
739
740// appendChild appends child, taking room for the n input children from the
741// arena on the first call so a node's children never regrow.
742func (c context) appendChild(children []*Result, child *Result, n int) []*Result {
743 if children == nil {
744 children = c.arena.children.take(n)
745 }
746 return append(children, child)
747}
748
749type flexItem struct {
750 node *Node
751 base, size, cross, grow, shrink, marginMain, marginCross float64 // sizes are border boxes
752 min, max float64
753 frozen bool
754}
755
756// flexInsets computes effective border-box insets, using the same border-style
757// treatment as place. Percentage padding is relative to the containing width.
758func flexInsets(s css.Style, basis float64) (float64, float64) {
759 p := nonnegative(edges(s.Padding, basis))
760 b := nonnegative(edges(s.BorderWidth, basis))
761 if s.BorderStyle.Top != css.KeywordSolid {
762 b.Top = 0
763 }
764 if s.BorderStyle.Right != css.KeywordSolid {
765 b.Right = 0
766 }
767 if s.BorderStyle.Bottom != css.KeywordSolid {
768 b.Bottom = 0
769 }
770 if s.BorderStyle.Left != css.KeywordSolid {
771 b.Left = 0
772 }
773 return p.horizontal() + b.horizontal(), p.vertical() + b.vertical()
774}
775func flexBounds(s css.Style, main bool, basis, inset float64) (float64, float64) {
776 minL, maxL := s.MinWidth, s.MaxWidth
777 if !main {
778 minL, maxL = s.MinHeight, s.MaxHeight
779 }
780 low, high := inset, float64(limit)
781 if v, ok := length(minL, basis); ok {
782 if s.BoxSizing != css.KeywordBorderBox {
783 low = math.Max(inset, safe(v)+inset)
784 } else {
785 low = math.Max(inset, safe(v))
786 }
787 }
788 if v, ok := length(maxL, basis); ok {
789 high = safe(v)
790 if s.BoxSizing != css.KeywordBorderBox {
791 high += inset
792 }
793 high = math.Max(high, inset)
794 }
795 return low, math.Max(low, high)
796}
797
798// resolveFlex freezes violating items, then redistributes the remaining free
799// space among unfrozen items. Shrink weights always use the original base.
800func resolveFlex(items []flexItem, available float64) {
801 total := 0.0
802 for i := range items {
803 it := &items[i]
804 it.size = it.base
805 it.frozen = false
806 total += it.base + it.marginMain
807 }
808 grow := available > total
809 // Each pass freezes at least one violator, or all remaining items.
810 for range items {
811 free, weight, factors := available, 0.0, 0.0
812 for i := range items {
813 it := &items[i]
814 free -= it.marginMain
815 if it.frozen {
816 free -= it.size
817 continue
818 }
819 free -= it.base
820 factor := it.grow
821 if !grow {
822 factor = it.shrink * it.base
823 }
824 weight += factor
825 if grow {
826 factors += it.grow
827 }
828 }
829 // CSS resolve-flexible-lengths limits growth when total grow factors < 1.
830 if grow && factors < 1 {
831 free *= factors
832 }
833 violation := 0.0
834 for i := range items {
835 it := &items[i]
836 if it.frozen {
837 continue
838 }
839 factor := it.grow
840 if !grow {
841 factor = it.shrink * it.base
842 }
843 tentative := it.base
844 if weight > 0 {
845 tentative += free * factor / weight
846 }
847 it.size = math.Max(it.min, math.Min(it.max, safe(tentative)))
848 violation += it.size - tentative
849 }
850 if math.Abs(violation) < 1e-9 {
851 for i := range items {
852 items[i].frozen = true
853 }
854 break
855 }
856 for i := range items {
857 it := &items[i]
858 if it.frozen {
859 continue
860 }
861 factor := it.grow
862 if !grow {
863 factor = it.shrink * it.base
864 }
865 tentative := it.base
866 if weight > 0 {
867 tentative += free * factor / weight
868 }
869 if violation > 0 && it.size > tentative || violation < 0 && it.size < tentative {
870 it.frozen = true
871 }
872 }
873 }
874}
875func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
876 row := s.FlexDirection != css.KeywordColumn
877 mainSize, crossSize := r.Content.W, r.Content.H
878 gap, crossGap := positive(s.ColumnGap, r.Content.W), positive(s.RowGap, r.Content.W)
879 if !row {
880 mainSize, crossSize = crossSize, mainSize
881 gap, crossGap = crossGap, gap
882 }
883 items := c.arena.flexItems.take(len(n.Children))
884 for _, ch := range n.Children {
885 if ch == nil || style(ch).Display == css.KeywordNone {
886 continue
887 }
888 st := style(ch)
889 m := edges(st.Margin, r.Content.W)
890 inW, inH := flexInsets(st, r.Content.W)
891 inMain, inCross := inW, inH
892 if !row {
893 inMain, inCross = inH, inW
894 }
895 it := flexItem{node: ch, grow: safe(st.FlexGrow), shrink: safe(st.FlexShrink)}
896 if row {
897 it.marginMain = m.horizontal()
898 it.marginCross = m.vertical()
899 } else {
900 it.marginMain = m.vertical()
901 it.marginCross = m.horizontal()
902 }
903 dim, crossDim := st.Width, st.Height
904 minCross, maxCross := st.MinHeight, st.MaxHeight
905 if !row {
906 dim, crossDim = st.Height, st.Width
907 minCross, maxCross = st.MinWidth, st.MaxWidth
908 }
909 measureWidth := 0.0
910 if row {
911 measureWidth = mainSize
912 } else {
913 measureWidth = crossSize
914 }
915 measured, _, err := c.intrinsicDepth(ch, measureWidth, 1)
916 if err != nil {
917 return err
918 }
919 intrinsicMain, intrinsicCross := measured.W, measured.H
920 if !row {
921 intrinsicMain, intrinsicCross = measured.H, measured.W
922 }
923 value, ok := length(st.FlexBasis, mainSize)
924 if !ok {
925 value, ok = length(dim, mainSize)
926 }
927 if ok {
928 it.base = safe(value)
929 if st.BoxSizing != css.KeywordBorderBox {
930 it.base += inMain
931 }
932 } else {
933 it.base = intrinsicMain + inMain
934 }
935 value, ok = length(crossDim, crossSize)
936 if ok {
937 it.cross = safe(value)
938 if st.BoxSizing != css.KeywordBorderBox {
939 it.cross += inCross
940 }
941 } else {
942 it.cross = intrinsicCross + inCross
943 }
944 it.min, it.max = flexBounds(st, row, mainSize, inMain)
945 it.cross = boxBound(it.cross, minCross, maxCross, crossSize, inCross, st.BoxSizing == css.KeywordBorderBox)
946 items = append(items, it)
947 }
948 // Each line is a run of consecutive items; ends holds each line's end index.
949 ends := c.arena.flexEnds.take(len(items))
950 start, used := 0, 0.0
951 for i, item := range items {
952 need := item.base + item.marginMain
953 if i > start {
954 need += gap
955 }
956 if s.FlexWrap == css.KeywordWrap && i > start && used+need > mainSize {
957 ends = append(ends, i)
958 start, used = i, 0
959 need = item.base + item.marginMain
960 }
961 used += need
962 }
963 if len(items) > start {
964 ends = append(ends, len(items))
965 }
966 crossPos := 0.0
967 lineStart := 0
968 for _, end := range ends {
969 line := items[lineStart:end:end]
970 lineStart = end
971 lineCross := 0.0
972 for _, it := range line {
973 lineCross = math.Max(lineCross, it.cross+it.marginCross)
974 }
975 if len(ends) == 1 && crossSize > 0 && (row && s.Height.Unit != "auto" && s.Height.Unit != "" || !row && s.Width.Unit != "auto" && s.Width.Unit != "") {
976 lineCross = math.Max(lineCross, crossSize)
977 }
978 resolveFlex(line, mainSize-gap*float64(len(line)-1))
979 occupied := gap * float64(len(line)-1)
980 for _, it := range line {
981 occupied += it.size + it.marginMain
982 }
983 remaining := safe(mainSize - occupied)
984 start, spacing := 0.0, gap
985 switch s.JustifyContent {
986 case css.KeywordFlexEnd:
987 start = remaining
988 case css.KeywordCenter:
989 start = remaining / 2
990 case css.KeywordSpaceBetween:
991 if len(line) > 1 {
992 spacing += remaining / float64(len(line)-1)
993 }
994 case css.KeywordSpaceAround:
995 spacing += remaining / float64(len(line))
996 start = remaining / float64(2*len(line))
997 case css.KeywordSpaceEvenly:
998 spacing += remaining / float64(len(line)+1)
999 start = remaining / float64(len(line)+1)
1000 }
1001 pos := start
1002 for _, it := range line {
1003 align := style(it.node).AlignSelf
1004 if align == css.KeywordAuto || align == css.KeywordNone {
1005 align = s.AlignItems
1006 }
1007 cross := it.cross
1008 if align == css.KeywordStretch {
1009 cross = safe(lineCross - it.marginCross)
1010 }
1011 offset := 0.0
1012 switch align {
1013 case css.KeywordCenter:
1014 offset = safe(lineCross-cross-it.marginCross) / 2
1015 case css.KeywordFlexEnd:
1016 offset = safe(lineCross - cross - it.marginCross)
1017 }
1018 main := safe(it.size)
1019 cross = safe(cross)
1020 var x, y float64
1021 var w, h *float64
1022 if row {
1023 x = r.Content.X + pos
1024 y = r.Content.Y + crossPos + offset
1025 w = &main
1026 h = &cross
1027 } else {
1028 x = r.Content.X + crossPos + offset
1029 y = r.Content.Y + pos
1030 w = &cross
1031 h = &main
1032 }
1033 child, err := c.place(it.node, x, y, r.Content.W, r.Content.H, w, h, depth+1)
1034 if err != nil {
1035 return err
1036 }
1037 if child != nil {
1038 r.Children = c.appendChild(r.Children, child, len(n.Children))
1039 cw, ch := envelope(child)
1040 r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw)
1041 r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch)
1042 }
1043 pos += main + it.marginMain + spacing
1044 }
1045 crossPos += lineCross + crossGap
1046 }
1047 return nil
1048}
1049
1050// slab hands out values or sub-slices from one block, so an Output costs no
1051// allocation per node, shadow, run or text command once the block is large
1052// enough. The block is reused by reset: pointers and slices from it are valid
1053// until the owning Arena is passed to Layout again. Sub-slices have their
1054// capacity clipped, so a caller's append cannot overwrite a neighbour. A
1055// miscount only falls back to individual allocations, and need grows so the
1056// next reset fits.
1057type slab[T any] struct {
1058 block []T
1059 need int // demand since the last reset, including fallbacks
1060}
1061
1062// retainSlack is how much larger than needed retained storage may be before
1063// it is dropped, so one huge frame does not pin its memory. render uses the
1064// same rule for its quad and instance buffers.
1065const retainSlack = 4
1066
1067// oversized reports that storage of the given capacity holds far more than
1068// need and should be dropped rather than reused.
1069func oversized(capacity, need int) bool { return capacity > retainSlack*need+64 }
1070
1071// reset recycles the block for n elements, zeroing the previous contents so
1072// they do not keep text, faces or images alive.
1073func (a *slab[T]) reset(n int) {
1074 clear(a.block)
1075 a.need = 0
1076 if cap(a.block) < n || oversized(cap(a.block), n) {
1077 a.block = make([]T, 0, n)
1078 return
1079 }
1080 a.block = a.block[:0]
1081}
1082
1083// one stores v in the slab and returns its address.
1084func (a *slab[T]) one(v T) *T {
1085 a.need++
1086 if len(a.block) == cap(a.block) {
1087 p := new(T) // copy, so v itself never escapes
1088 *p = v
1089 return p
1090 }
1091 a.block = append(a.block, v)
1092 return &a.block[len(a.block)-1]
1093}
1094
1095// take returns an empty slice with capacity exactly n (nil for n == 0).
1096func (a *slab[T]) take(n int) []T {
1097 if n <= 0 {
1098 return nil
1099 }
1100 a.need += n
1101 if cap(a.block)-len(a.block) < n {
1102 return make([]T, 0, n)
1103 }
1104 i := len(a.block)
1105 a.block = a.block[:i+n]
1106 return a.block[i : i : i+n]
1107}
1108
1109// radiiOf resolves border-radius percentages against basis.
1110func radiiOf(s *css.Style, basis float64) [4]float64 {
1111 return [4]float64{positive(s.BorderRadius.TopLeft, basis), positive(s.BorderRadius.TopRight, basis), positive(s.BorderRadius.BottomRight, basis), positive(s.BorderRadius.BottomLeft, basis)}
1112}
1113
1114func hasOutline(s *css.Style, r *Result) bool {
1115 return s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0
1116}
1117
1118// countCommands returns how many commands and shadow copies paint emits for
1119// the subtree, so Layout can size the display list once. It mirrors paint's
1120// conditions; if it ever drifts, paint still appends correctly and only regrows.
1121func countCommands(n *Node, r *Result) (c counts) {
1122 if r == nil {
1123 return c
1124 }
1125 s := style(n)
1126 total := len(s.BoxShadow)
1127 c.shadows = len(s.BoxShadow)
1128 if s.BackgroundColor.A > 0 {
1129 total++
1130 }
1131 if r.BorderWidths != (Edges{}) || radiiOf(&s, r.Border.W) != ([4]float64{}) {
1132 total++
1133 }
1134 if hasOutline(&s, r) {
1135 total++
1136 }
1137 if r.Clipped {
1138 total += 2
1139 }
1140 if n.Kind == Image || n.Kind == Text {
1141 total++
1142 }
1143 if n.Kind == Text {
1144 c.runs, c.glyphs = textCounts(r)
1145 }
1146 j := 0
1147 for _, child := range n.Children {
1148 if child == nil || style(child).Display == css.KeywordNone {
1149 continue
1150 }
1151 if j < len(r.Children) {
1152 cc := countCommands(child, r.Children[j])
1153 total += cc.commands
1154 c.shadows += cc.shadows
1155 c.runs += cc.runs
1156 c.glyphs += cc.glyphs
1157 j++
1158 }
1159 }
1160 c.commands += total
1161 return c
1162}
1163
1164type counts struct{ commands, shadows, runs, glyphs int }
1165
1166func textCounts(r *Result) (runs, glyphs int) {
1167 for i := range r.Lines {
1168 runs += len(r.Lines[i].Runs)
1169 for j := range r.Lines[i].Runs {
1170 glyphs += len(r.Lines[i].Runs[j].Glyphs)
1171 }
1172 }
1173 return runs, glyphs
1174}
1175
1176func (c context) paint(n *Node, r *Result, out *[]Command) {
1177 if r == nil {
1178 return
1179 }
1180 s := style(n)
1181 base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))}
1182 borderRadii := radiiOf(&s, r.Border.W)
1183 for i := range s.BoxShadow {
1184 if s.BoxShadow[i].Inset {
1185 continue
1186 }
1187 cmd := base
1188 cmd.Op = "shadow"
1189 cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i])
1190 cmd.Radii = borderRadii
1191 *out = append(*out, cmd)
1192 }
1193 if s.BackgroundColor.A > 0 {
1194 cmd := base
1195 cmd.Op = "rect"
1196 cmd.Color = s.BackgroundColor
1197 cmd.Radii = borderRadii
1198 *out = append(*out, cmd)
1199 }
1200 cmd := base
1201 cmd.Op = "border"
1202 cmd.Widths = r.BorderWidths
1203 cmd.Colors = s.BorderColor
1204 cmd.Radii = borderRadii
1205 if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) {
1206 cmd.Color = s.BorderColor.Top
1207 *out = append(*out, cmd)
1208 }
1209 for i := range s.BoxShadow {
1210 if !s.BoxShadow[i].Inset {
1211 continue
1212 }
1213 cmd := base
1214 cmd.Op = "shadow"
1215 cmd.Shadow = c.arena.shadows.one(s.BoxShadow[i])
1216 cmd.Radii = borderRadii
1217 *out = append(*out, cmd)
1218 }
1219 if hasOutline(&s, r) {
1220 cmd = base
1221 cmd.Op = "outline"
1222 cmd.Color = s.OutlineColor
1223 cmd.Radii = borderRadii
1224 width := positive(s.OutlineWidth, r.Border.W)
1225 cmd.Widths = Edges{Top: width, Right: width, Bottom: width, Left: width}
1226 offset := coordValue(s.OutlineOffset, r.Border.W)
1227 cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)}
1228 *out = append(*out, cmd)
1229 }
1230 if r.Clipped {
1231 cmd = base
1232 cmd.Op = "clip-push"
1233 cmd.Rect = r.Clip
1234 *out = append(*out, cmd)
1235 }
1236 if n.Kind == Image {
1237 cmd = base
1238 cmd.Op = "image"
1239 cmd.Rect = r.Content
1240 cmd.Image = n.Image
1241 cmd.Radii = radiiOf(&s, r.Content.W)
1242 *out = append(*out, cmd)
1243 }
1244 if n.Kind == Text {
1245 cmd = base
1246 cmd.Op = "text"
1247 cmd.Rect = r.Content
1248 cmd.Text = n.Content
1249 cmd.Color = s.Color
1250 runs, glyphs := textCounts(r)
1251 cmd.Runs = c.arena.runs.take(runs)
1252 backing := c.arena.glyphs.take(glyphs)
1253 for _, line := range r.Lines {
1254 offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width)
1255 for _, run := range line.Runs {
1256 rr := Run{Start: run.Start, End: run.End, X: run.X + r.Content.X + offset - r.ScrollX, Y: run.Y + r.Content.Y - r.ScrollY, Advance: run.Advance, Face: run.Face, Size: s.FontSize.Value}
1257 if rr.Size <= 0 {
1258 rr.Size = 16 // matches the intrinsic text.Measure request fallback
1259 }
1260 if run.Face != nil {
1261 rr.FaceID = run.Face.ID
1262 }
1263 if len(run.Glyphs) > 0 { // keep nil for glyph-less runs
1264 first := len(backing)
1265 for _, g := range run.Glyphs {
1266 backing = append(backing, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster})
1267 }
1268 rr.Glyphs = backing[first:len(backing):len(backing)]
1269 }
1270 cmd.Runs = append(cmd.Runs, rr)
1271 }
1272 }
1273 *out = append(*out, cmd)
1274 }
1275 j := 0
1276 for _, child := range n.Children {
1277 if child == nil || style(child).Display == css.KeywordNone {
1278 continue
1279 }
1280 if j < len(r.Children) {
1281 c.paint(child, r.Children[j], out)
1282 j++
1283 }
1284 }
1285 if r.Clipped {
1286 cmd = base
1287 cmd.Op = "clip-pop"
1288 *out = append(*out, cmd)
1289 }
1290}
1291
1292// AlignOffset is the horizontal offset of a line of width lineW inside a
1293// content box of width contentW under text-align and the line's direction.
1294func AlignOffset(align css.Keyword, direction di.Direction, contentW, lineW float64) float64 {
1295 switch align {
1296 case css.KeywordCenter:
1297 return safe(contentW-lineW) / 2
1298 case css.KeywordRight:
1299 return safe(contentW - lineW)
1300 case css.KeywordStart:
1301 if direction == di.DirectionRTL {
1302 return safe(contentW - lineW)
1303 }
1304 case css.KeywordEnd:
1305 if direction != di.DirectionRTL {
1306 return safe(contentW - lineW)
1307 }
1308 }
1309 return 0
1310}
1311
1312// Hit returns the frontmost node ID whose border box contains the logical point.
1313// Ancestor content clips constrain descendants, but not the ancestor border itself.
1314func Hit(r *Result, x, y float64) string {
1315 if math.IsNaN(x) || math.IsNaN(y) || math.IsInf(x, 0) || math.IsInf(y, 0) {
1316 return ""
1317 }
1318 return hit(r, x, y, Rect{-limit, -limit, 2 * limit, 2 * limit})
1319}
1320func hit(r *Result, x, y float64, clip Rect) string {
1321 if r == nil || !clip.Contains(x, y) {
1322 return ""
1323 }
1324 childClip := clip
1325 if r.Clipped {
1326 childClip = clip.intersect(r.Clip)
1327 }
1328 if childClip.Contains(x, y) {
1329 for i := len(r.Children) - 1; i >= 0; i-- {
1330 if id := hit(r.Children[i], x, y, childClip); id != "" {
1331 return id
1332 }
1333 }
1334 }
1335 if r.Border.Contains(x, y) {
1336 return r.ID
1337 }
1338 return ""
1339}
1340
1341// Physical is the explicit renderer boundary; all layout and hit testing stay logical.
1342// It rounds edges independently to avoid accumulated fractional-scale drift.
1343func Physical(r Rect, scale float64) (x, y, w, h int) {
1344 if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 {
1345 return
1346 }
1347 scale = math.Min(scale, 1024)
1348 x = int(math.Round(coord(r.X) * scale))
1349 y = int(math.Round(coord(r.Y) * scale))
1350 w = int(math.Round(coord(r.X+safe(r.W))*scale)) - x
1351 h = int(math.Round(coord(r.Y+safe(r.H))*scale)) - y
1352 return
1353}