v0.7.1 e0abc49

layout.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close