v0.1.0 47aaa0f

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

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