gitstack

grindlemire/go-tui code browser

7.9 KB Go 250 lines 2026-06-03 · d15bb9f raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
package layout

import "math"

// Calculate performs layout calculation on the tree rooted at root.
// The root and all descendants will have their Layout field populated.
// Only dirty nodes are recalculated (incremental layout).
//
// availableWidth and availableHeight specify the root constraint
// (typically the terminal size).
func Calculate(root Layoutable, availableWidth, availableHeight int) {
	if root == nil {
		return
	}

	// For the root node, resolve its width/height constraints against
	// the available space. This is different from child nodes, which
	// receive their size from the parent's flex calculations.
	style := root.LayoutStyle()
	width := style.Width.Resolve(availableWidth, availableWidth)
	height := style.Height.Resolve(availableHeight, availableHeight)

	available := NewRect(0, 0, width, height)
	calculateNode(root, available, 0.0, 0.0)
}

// calculateNode computes the layout for a single node within the available space.
// The available rect represents the border box space allocated by the parent
// (after the parent has already applied this node's margin).
//
// absoluteX and absoluteY are the true float positions passed from the parent.
// This enables Yoga-style rounding: we track float positions through the tree
// and only round once when computing the final integer Rect.
func calculateNode(node Layoutable, available Rect, absoluteX, absoluteY float64) {
	// Dirty propagates up, so a clean node guarantees a clean subtree
	if !node.IsDirty() {
		return
	}

	style := node.LayoutStyle()

	// 1. Compute this node's border box dimensions (width/height only)
	borderBox := computeBorderBox(style, available)

	// Pre-sizing pass for flex-wrap containers with auto cross-axis size.
	// Determines how many wrap lines are needed and adjusts the border box
	// cross dimension before running the full layout.
	if style.FlexWrap != WrapNone && len(node.LayoutChildren()) > 0 && node.Tag() != "table" {
		isRow := style.Direction == Row
		if style.Display == DisplayBlock {
			isRow = false
		}

		// Check if cross-axis is Auto
		crossIsAuto := false
		if isRow {
			crossIsAuto = style.Height.IsAuto()
		} else {
			crossIsAuto = style.Width.IsAuto()
		}

		if crossIsAuto {
			contentWidth := borderBox.Width - style.Padding.Horizontal()
			contentHeight := borderBox.Height - style.Padding.Vertical()

			mainSz := contentWidth
			crossSz := contentHeight
			if !isRow {
				mainSz, crossSz = crossSz, mainSz
			}

			// Quick Phase 1: compute base sizes
			children := node.LayoutChildren()
			preItems := make([]flexItem, len(children))
			for i, child := range children {
				childStyle := child.LayoutStyle()
				var mainMargin int
				if isRow {
					mainMargin = childStyle.Margin.Horizontal()
				} else {
					mainMargin = childStyle.Margin.Vertical()
				}
				childIntrinsicW, childIntrinsicH := child.IntrinsicSize()
				if isRow {
					preItems[i].baseSize = childStyle.Width.Resolve(mainSz, childIntrinsicW) + mainMargin
				} else {
					preItems[i].baseSize = childStyle.Height.Resolve(mainSz, childIntrinsicH) + mainMargin
				}
			}

			// Break into lines
			preLines := breakIntoLines(preItems, mainSz, style.Gap)

			// Measure cross size per line
			totalCross := 0
			for _, pl := range preLines {
				maxCross := 0
				for j := pl.startIdx; j < pl.endIdx; j++ {
					child := children[j]
					childStyle := child.LayoutStyle()
					childIntrinsicW, childIntrinsicH := child.IntrinsicSize()

					var cross int
					if isRow {
						childWidth := preItems[j].baseSize - childStyle.Margin.Horizontal()
						wrappedH := child.HeightForWidth(childWidth)
						cross = max(wrappedH, childIntrinsicH)
						cross += childStyle.Margin.Vertical()
					} else {
						cross = childIntrinsicW + childStyle.Margin.Horizontal()
					}

					// Use explicit cross size if set
					var crossStyleValue Value
					var crossMargin int
					if isRow {
						crossStyleValue = childStyle.Height
						crossMargin = childStyle.Margin.Vertical()
					} else {
						crossStyleValue = childStyle.Width
						crossMargin = childStyle.Margin.Horizontal()
					}
					if !crossStyleValue.IsAuto() {
						cross = crossStyleValue.Resolve(crossSz-crossMargin, 0) + crossMargin
					}

					if cross > maxCross {
						maxCross = cross
					}
				}
				totalCross += maxCross
			}

			// Adjust border box cross dimension to fit wrapped content.
			// When the node has flex-grow and its cross axis was allocated
			// by the parent's flex algorithm, only expand (never shrink).
			// Otherwise (truly auto-sized), set to content size exactly.
			// NOTE: FlexGrow > 0 is a heuristic for "parent allocated the
			// cross axis". It can over-size containers when the parent uses
			// AlignItems: AlignStart (the parent allocates full line height
			// but doesn't stretch the child). The extra space goes unused
			// and won't cause incorrect rendering. A more precise check
			// would require threading the parent's align mode into this pass.
			flexAllocated := style.FlexGrow > 0
			if isRow {
				needed := totalCross + style.Padding.Vertical()
				if flexAllocated {
					if needed > borderBox.Height {
						borderBox.Height = needed
					}
				} else {
					borderBox.Height = needed
				}
			} else {
				needed := totalCross + style.Padding.Horizontal()
				if flexAllocated {
					if needed > borderBox.Width {
						borderBox.Width = needed
					}
				} else {
					borderBox.Width = needed
				}
			}
		}
	}

	// 2. Set border box position from the rounded absolute float position
	borderBox.X = int(math.Round(absoluteX))
	borderBox.Y = int(math.Round(absoluteY))

	// 3. Compute content rect position from float position (then round)
	contentAbsX := absoluteX + float64(style.Padding.Left)
	contentAbsY := absoluteY + float64(style.Padding.Top)
	contentRect := Rect{
		X:      int(math.Round(contentAbsX)),
		Y:      int(math.Round(contentAbsY)),
		Width:  borderBox.Width - style.Padding.Horizontal(),
		Height: borderBox.Height - style.Padding.Vertical(),
	}

	// 4. Layout children within content rect, passing float positions
	children := node.LayoutChildren()
	if len(children) > 0 {
		if node.Tag() == "table" {
			layoutTable(node, contentRect, contentAbsX, contentAbsY)
		} else {
			layoutChildren(node, contentRect, contentAbsX, contentAbsY)
		}
	}

	// 5. Store computed layout with float positions for child calculations
	node.SetLayout(Layout{
		Rect:        borderBox,
		ContentRect: contentRect,
		AbsoluteX:   absoluteX,
		AbsoluteY:   absoluteY,
	})

	// 6. Clear dirty flag
	node.SetDirty(false)
}

// computeBorderBox calculates the border box dimensions for a node.
// The available rect is the space allocated by the parent (after margin and flex).
// For flex children, the available rect already contains the flex-computed size,
// so this function just uses the available dimensions directly.
// Only min/max constraints are applied; Width/Height were already used by the
// flex algorithm to compute the slot size.
func computeBorderBox(style Style, available Rect) Rect {
	// Start with available dimensions (flex-computed or parent-allocated)
	width := available.Width
	height := available.Height

	// Apply min/max width constraints
	minWidth := style.MinWidth.Resolve(available.Width, 0)
	maxWidth := style.MaxWidth.Resolve(available.Width, available.Width)
	width = clamp(width, minWidth, maxWidth)

	// Apply min/max height constraints
	minHeight := style.MinHeight.Resolve(available.Height, 0)
	maxHeight := style.MaxHeight.Resolve(available.Height, available.Height)
	height = clamp(height, minHeight, maxHeight)

	// Clamp to non-negative
	if width < 0 {
		width = 0
	}
	if height < 0 {
		height = 0
	}

	return Rect{
		X:      available.X,
		Y:      available.Y,
		Width:  width,
		Height: height,
	}
}

// clamp restricts v to the range [minVal, maxVal].
// If minVal > maxVal, minVal wins (matches CSS behavior).
func clamp(v, minVal, maxVal int) int {
	if v < minVal {
		return minVal
	}
	if maxVal >= minVal && v > maxVal {
		return maxVal
	}
	return v
}