openpencil/packages/core/src/layout.ts
Anton A S ba4333d798 Fix fill text MeasureFunc constraint, strengthen nested flex prompt
layout.ts: When text fills width (stretch in col parent), MeasureFunc
now uses Yoga's constraint width directly instead of clamping to
the 100px default fixedWidth.

prompt: Emphasize that ALL intermediate flex containers need w="fill",
not just the first level. grow inside HUG = zero width.
2026-03-13 10:17:18 +03:00

629 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable max-lines -- layout tree building is a single recursive pass */
import Yoga, {
Align,
Direction,
Display,
FlexDirection,
GridTrackType,
Gutter,
Justify,
Edge,
MeasureMode,
Overflow,
PositionType,
Wrap,
type Node as YogaNode
} from 'yoga-layout'
import type { GridTrack, SceneGraph, SceneNode } from './scene-graph'
export type TextMeasurer = (
node: SceneNode,
maxWidth?: number
) => { width: number; height: number } | null
let globalTextMeasurer: TextMeasurer | null = null
const GLYPH_WIDTH_FACTOR = 0.6
export function setTextMeasurer(measurer: TextMeasurer | null): void {
globalTextMeasurer = measurer
}
export function computeLayout(graph: SceneGraph, frameId: string): void {
const frame = graph.getNode(frameId)
if (!frame || frame.layoutMode === 'NONE') return
const yogaRoot =
frame.layoutMode === 'GRID' ? buildGridTree(graph, frame) : buildYogaTree(graph, frame)
yogaRoot.calculateLayout(undefined, undefined, Direction.LTR)
applyYogaLayout(graph, frame, yogaRoot)
freeYogaTree(yogaRoot)
}
export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void {
const visited = new Set<string>()
computeLayoutsBottomUp(graph, scopeId ?? graph.rootId, visited)
}
function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<string>): void {
const node = graph.getNode(nodeId)
if (!node || visited.has(nodeId)) return
visited.add(nodeId)
for (const childId of node.childIds) {
computeLayoutsBottomUp(graph, childId, visited)
}
if (node.layoutMode !== 'NONE') {
computeLayout(graph, nodeId)
}
}
// --- Grid layout ---
function mapGridTrack(track: GridTrack): { type: GridTrackType; value: number } {
switch (track.sizing) {
case 'FR':
return { type: GridTrackType.Fr, value: track.value }
case 'FIXED':
return { type: GridTrackType.Points, value: track.value }
default:
return { type: GridTrackType.Auto, value: 0 }
}
}
function configureAsGrid(yogaNode: YogaNode, node: SceneNode): void {
yogaNode.setDisplay(Display.Grid)
yogaNode.setWidth(node.width)
if (node.gridTemplateRows.length > 0 || node.height > 0) {
yogaNode.setHeight(node.height)
}
if (node.gridTemplateColumns.length > 0) {
yogaNode.setGridTemplateColumns(node.gridTemplateColumns.map(mapGridTrack))
}
if (node.gridTemplateRows.length > 0) {
yogaNode.setGridTemplateRows(node.gridTemplateRows.map(mapGridTrack))
}
yogaNode.setGap(Gutter.Column, node.gridColumnGap)
yogaNode.setGap(Gutter.Row, node.gridRowGap)
yogaNode.setPadding(Edge.Top, node.paddingTop)
yogaNode.setPadding(Edge.Right, node.paddingRight)
yogaNode.setPadding(Edge.Bottom, node.paddingBottom)
yogaNode.setPadding(Edge.Left, node.paddingLeft)
}
function createGridChildNode(child: SceneNode): YogaNode {
const yogaChild = Yoga.Node.create()
if (!child.visible) {
yogaChild.setDisplay(Display.None)
} else {
const pos = child.gridPosition
if (pos) {
yogaChild.setGridColumnStart(pos.column)
yogaChild.setGridColumnEndSpan(pos.columnSpan)
yogaChild.setGridRowStart(pos.row)
yogaChild.setGridRowEndSpan(pos.rowSpan)
}
const hasLayout = child.layoutMode !== 'NONE'
const explicitStretch = child.layoutGrow > 0 || child.layoutAlignSelf === 'STRETCH'
if (explicitStretch || hasLayout) {
yogaChild.setWidthStretch()
} else {
yogaChild.setWidth(child.width)
}
if (explicitStretch) {
yogaChild.setHeightStretch()
} else {
yogaChild.setHeight(child.height)
}
}
return yogaChild
}
function buildGridTree(graph: SceneGraph, frame: SceneNode): YogaNode {
const root = Yoga.Node.create()
configureAsGrid(root, frame)
const children = graph.getChildren(frame.id)
for (const child of children) {
if (child.layoutPositioning === 'ABSOLUTE') {
const yogaChild = Yoga.Node.create()
configureAbsoluteChild(yogaChild, child)
root.insertChild(yogaChild, root.getChildCount())
} else {
root.insertChild(createGridChildNode(child), root.getChildCount())
}
}
return root
}
// --- Flex layout ---
function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode {
const root = Yoga.Node.create()
if (frame.primaryAxisSizing === 'FIXED') {
if (frame.layoutMode === 'HORIZONTAL') root.setWidth(frame.width)
else root.setHeight(frame.height)
}
if (frame.counterAxisSizing === 'FIXED') {
if (frame.layoutMode === 'HORIZONTAL') root.setHeight(frame.height)
else root.setWidth(frame.width)
}
configureFlexContainer(root, frame)
const children = graph.getChildren(frame.id)
for (const child of children) {
const yogaChild = Yoga.Node.create()
if (child.layoutPositioning === 'ABSOLUTE') {
configureAbsoluteChild(yogaChild, child)
} else if (!child.visible) {
yogaChild.setDisplay(Display.None)
} else if (child.layoutMode === 'GRID') {
configureChildAsGrid(yogaChild, child, frame, graph)
} else if (child.layoutMode !== 'NONE') {
configureChildAsAutoLayout(yogaChild, child, frame, graph)
} else {
configureChildAsLeaf(yogaChild, child, frame)
}
root.insertChild(yogaChild, root.getChildCount())
}
return root
}
function configureAbsoluteChild(yogaChild: YogaNode, child: SceneNode): void {
yogaChild.setPositionType(PositionType.Absolute)
yogaChild.setPosition(Edge.Left, child.x)
yogaChild.setPosition(Edge.Top, child.y)
yogaChild.setWidth(child.width)
yogaChild.setHeight(child.height)
}
function configureFlexContainer(yogaNode: YogaNode, node: SceneNode): void {
yogaNode.setFlexDirection(
node.layoutMode === 'HORIZONTAL' ? FlexDirection.Row : FlexDirection.Column
)
yogaNode.setFlexWrap(node.layoutWrap === 'WRAP' ? Wrap.Wrap : Wrap.NoWrap)
yogaNode.setJustifyContent(mapJustify(node.primaryAxisAlign))
yogaNode.setAlignItems(mapAlign(node.counterAxisAlign))
if (node.clipsContent) yogaNode.setOverflow(Overflow.Hidden)
if (node.layoutWrap === 'WRAP' && node.counterAxisAlignContent === 'SPACE_BETWEEN') {
yogaNode.setAlignContent(Align.SpaceBetween)
}
yogaNode.setPadding(Edge.Top, node.paddingTop)
yogaNode.setPadding(Edge.Right, node.paddingRight)
yogaNode.setPadding(Edge.Bottom, node.paddingBottom)
yogaNode.setPadding(Edge.Left, node.paddingLeft)
yogaNode.setGap(
Gutter.Column,
node.layoutMode === 'HORIZONTAL' ? node.itemSpacing : node.counterAxisSpacing
)
yogaNode.setGap(
Gutter.Row,
node.layoutMode === 'HORIZONTAL' ? node.counterAxisSpacing : node.itemSpacing
)
applyMinMaxConstraints(yogaNode, node)
}
function configureChildAsGrid(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
graph: SceneGraph
): void {
yogaChild.setDisplay(Display.Grid)
if (child.gridTemplateColumns.length > 0) {
yogaChild.setGridTemplateColumns(child.gridTemplateColumns.map(mapGridTrack))
}
if (child.gridTemplateRows.length > 0) {
yogaChild.setGridTemplateRows(child.gridTemplateRows.map(mapGridTrack))
}
yogaChild.setGap(Gutter.Column, child.gridColumnGap)
yogaChild.setGap(Gutter.Row, child.gridRowGap)
yogaChild.setPadding(Edge.Top, child.paddingTop)
yogaChild.setPadding(Edge.Right, child.paddingRight)
yogaChild.setPadding(Edge.Bottom, child.paddingBottom)
yogaChild.setPadding(Edge.Left, child.paddingLeft)
const isParentRow = parent.layoutMode === 'HORIZONTAL'
const selfOverride = child.layoutAlignSelf !== 'AUTO'
const stretchCross = selfOverride
? child.layoutAlignSelf === 'STRETCH'
: parent.counterAxisAlign === 'STRETCH'
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
yogaChild.setFlexShrink(1)
yogaChild.setFlexBasis(0)
if (!stretchCross) {
if (isParentRow) yogaChild.setHeight(child.height)
else yogaChild.setWidth(child.width)
}
} else {
if (isParentRow) {
yogaChild.setWidth(child.width)
if (!stretchCross) yogaChild.setHeight(child.height)
} else {
if (child.gridTemplateRows.length > 0) yogaChild.setHeight(child.height)
if (!stretchCross) yogaChild.setWidth(child.width)
}
}
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
applyMinMaxConstraints(yogaChild, child)
const grandchildren = graph.getChildren(child.id)
for (const gc of grandchildren) {
if (gc.layoutPositioning === 'ABSOLUTE') {
const yogaGC = Yoga.Node.create()
configureAbsoluteChild(yogaGC, gc)
yogaChild.insertChild(yogaGC, yogaChild.getChildCount())
} else {
yogaChild.insertChild(createGridChildNode(gc), yogaChild.getChildCount())
}
}
}
function applyMinMaxConstraints(yogaNode: YogaNode, node: SceneNode): void {
if (node.minWidth != null) yogaNode.setMinWidth(node.minWidth)
if (node.maxWidth != null) yogaNode.setMaxWidth(node.maxWidth)
if (node.minHeight != null) yogaNode.setMinHeight(node.minHeight)
if (node.maxHeight != null) yogaNode.setMaxHeight(node.maxHeight)
}
function configureChildAsAutoLayout(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
graph: SceneGraph
): void {
const isParentRow = parent.layoutMode === 'HORIZONTAL'
const isChildRow = child.layoutMode === 'HORIZONTAL'
const widthSizing = isChildRow ? child.primaryAxisSizing : child.counterAxisSizing
const heightSizing = isChildRow ? child.counterAxisSizing : child.primaryAxisSizing
if (isParentRow) {
setSizing(yogaChild, 'width', widthSizing, child.width, child.layoutGrow)
setSizing(yogaChild, 'height', heightSizing, child.height, 0)
} else {
setSizing(yogaChild, 'width', widthSizing, child.width, 0)
setSizing(yogaChild, 'height', heightSizing, child.height, child.layoutGrow)
}
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
configureFlexContainer(yogaChild, child)
const grandchildren = graph.getChildren(child.id)
for (const gc of grandchildren) {
const yogaGC = Yoga.Node.create()
if (gc.layoutPositioning === 'ABSOLUTE') {
configureAbsoluteChild(yogaGC, gc)
} else if (!gc.visible) {
yogaGC.setDisplay(Display.None)
} else if (gc.layoutMode === 'GRID') {
configureChildAsGrid(yogaGC, gc, child, graph)
} else if (gc.layoutMode !== 'NONE') {
configureChildAsAutoLayout(yogaGC, gc, child, graph)
} else {
configureChildAsLeaf(yogaGC, gc, child)
}
yogaChild.insertChild(yogaGC, yogaChild.getChildCount())
}
}
// Rough estimate for text size when CanvasKit/font is not available.
// Uses ~0.6 × fontSize per character as average glyph width.
// DO NOT REMOVE: without this, text nodes keep their 100×100 default size
// and blow up every HUG container. The real MeasureFunc (CanvasKit) overrides
// this when available — this is only the fallback.
const GLYPH_WIDTH_FACTOR = 0.6
function estimateTextSize(node: SceneNode): { width: number; height: number } {
const fontSize = node.fontSize || 14
const text = node.characters || node.text || ''
const lineHeight = fontSize * 1.2
const charWidth = fontSize * GLYPH_WIDTH_FACTOR
const width = Math.ceil(text.length * charWidth)
const height = Math.ceil(lineHeight)
return { width, height }
}
function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneNode): void {
const isRow = parent.layoutMode === 'HORIZONTAL'
const selfOverride = child.layoutAlignSelf !== 'AUTO'
const stretchCross = selfOverride
? child.layoutAlignSelf === 'STRETCH'
: parent.counterAxisAlign === 'STRETCH'
const isText = child.type === 'TEXT'
const needsMeasureFunc =
isText && globalTextMeasurer && child.textAutoResize !== 'NONE'
if (needsMeasureFunc) {
configureTextLeaf(yogaChild, child, parent)
} else if (isText && !globalTextMeasurer && child.textAutoResize !== 'NONE') {
// No CanvasKit — use rough estimate so HUG containers don't inherit
// the 100×100 default SceneNode size. See estimateTextSize above.
const est = estimateTextSize(child)
if (child.textAutoResize === 'WIDTH_AND_HEIGHT') {
yogaChild.setWidth(est.width)
yogaChild.setHeight(est.height)
} else if (child.textAutoResize === 'HEIGHT') {
const stretches = child.layoutAlignSelf === 'STRETCH' ||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
if (!(!isRow && stretches)) {
yogaChild.setWidth(child.width)
}
yogaChild.setHeight(est.height)
}
} else {
configureNonTextLeaf(yogaChild, child, isRow, stretchCross)
}
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
applyMinMaxConstraints(yogaChild, child)
}
function configureTextLeaf(
yogaChild: YogaNode,
child: SceneNode,
_parent: SceneNode
): void {
const autoResize = child.textAutoResize
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
}
const cache = new Map<number, { width: number; height: number }>()
const UNCONSTRAINED_KEY = -1
if (autoResize === 'WIDTH_AND_HEIGHT') {
yogaChild.setMeasureFunc((width, widthMode, _height, _heightMode) => {
const maxW = widthMode === MeasureMode.Undefined ? undefined : width
const cacheKey = maxW === undefined ? UNCONSTRAINED_KEY : Math.round(maxW)
const cached = cache.get(cacheKey)
if (cached) return cached
const measured = globalTextMeasurer?.(child, maxW)
const result = measured ?? estimateTextSize(child)
cache.set(cacheKey, result)
return result
})
} else if (autoResize === 'HEIGHT') {
const stretchesCross = child.layoutAlignSelf === 'STRETCH' ||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
// Don't set fixed width when text stretches on cross axis (w="fill" in
// flex="col" parent) — setWidth blocks Yoga's alignSelf:stretch, leaving
// text at 100px default instead of filling the parent.
const fillsWidth = !isRow && stretchesCross
const fixedWidth = child.width
if (child.layoutGrow <= 0 && !fillsWidth) {
yogaChild.setWidth(fixedWidth)
}
yogaChild.setMeasureFunc((width, widthMode, _height, _heightMode) => {
const constraintW = fillsWidth
? (widthMode === MeasureMode.Undefined ? fixedWidth : width)
: (widthMode === MeasureMode.Undefined ? fixedWidth : Math.min(width, fixedWidth || width))
const cacheKey = Math.round(constraintW)
const cached = cache.get(cacheKey)
if (cached) return cached
const measured = globalTextMeasurer?.(child, constraintW)
const result = { width: constraintW, height: measured?.height ?? estimateTextSize(child).height }
cache.set(cacheKey, result)
return result
})
}
}
function configureNonTextLeaf(
yogaChild: YogaNode,
child: SceneNode,
isRow: boolean,
stretchCross: boolean
): void {
const w = child.width
const h = child.height
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
if (!stretchCross) {
if (isRow) yogaChild.setHeight(h)
else yogaChild.setWidth(w)
}
} else {
if (isRow) {
yogaChild.setWidth(w)
if (!stretchCross) yogaChild.setHeight(h)
} else {
yogaChild.setHeight(h)
if (!stretchCross) yogaChild.setWidth(w)
}
}
}
function setSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
fixedValue: number,
grow: number
): void {
if (grow > 0) {
yogaNode.setFlexGrow(grow)
yogaNode.setFlexShrink(1)
yogaNode.setFlexBasis(0)
return
}
switch (sizing) {
case 'FIXED':
if (axis === 'width') yogaNode.setWidth(fixedValue)
else yogaNode.setHeight(fixedValue)
break
case 'HUG':
break
case 'FILL':
yogaNode.setFlexGrow(1)
yogaNode.setFlexShrink(1)
yogaNode.setFlexBasis(0)
break
}
}
function applyFrameSize(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void {
if (frame.layoutMode === 'GRID') {
if (frame.gridTemplateRows.length === 0) {
graph.updateNode(frame.id, { height: yogaNode.getComputedHeight() })
}
return
}
if (frame.primaryAxisSizing !== 'HUG' && frame.counterAxisSizing !== 'HUG') return
const computedW = yogaNode.getComputedWidth()
const computedH = yogaNode.getComputedHeight()
const updates: Partial<SceneNode> = {}
if (frame.primaryAxisSizing === 'HUG') {
if (frame.layoutMode === 'HORIZONTAL') updates.width = computedW
else updates.height = computedH
}
if (frame.counterAxisSizing === 'HUG') {
if (frame.layoutMode === 'HORIZONTAL') updates.height = computedH
else updates.width = computedW
}
graph.updateNode(frame.id, updates)
}
function applyYogaLayout(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void {
applyFrameSize(graph, frame, yogaNode)
const children = graph.getChildren(frame.id)
let yogaIndex = 0
for (const child of children) {
const yogaChild = yogaNode.getChild(yogaIndex)
yogaIndex++
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!yogaChild) continue
if (child.visible && child.layoutPositioning !== 'ABSOLUTE') {
graph.updateNode(child.id, {
x: yogaChild.getComputedLeft(),
y: yogaChild.getComputedTop(),
width: yogaChild.getComputedWidth(),
height: yogaChild.getComputedHeight()
})
}
if (child.layoutMode !== 'NONE') {
if (child.layoutMode === 'GRID' && child.visible && child.layoutPositioning !== 'ABSOLUTE') {
computeLayout(graph, child.id)
} else if (frame.layoutMode === 'GRID' && child.visible && child.layoutPositioning !== 'ABSOLUTE') {
recomputeGridChild(graph, child)
} else {
applyYogaLayout(graph, child, yogaChild)
}
}
}
}
function recomputeGridChild(graph: SceneGraph, child: SceneNode): void {
const updated = graph.getNode(child.id)
if (!updated || updated.layoutMode === 'NONE') return
const savedPrimary = updated.primaryAxisSizing
const savedCounter = updated.counterAxisSizing
const updates: Partial<SceneNode> = {}
if (savedPrimary === 'HUG') updates.primaryAxisSizing = 'FIXED'
if (savedCounter === 'HUG') updates.counterAxisSizing = 'FIXED'
if (Object.keys(updates).length > 0) graph.updateNode(child.id, updates)
computeLayout(graph, child.id)
const restore: Partial<SceneNode> = {}
if (updates.primaryAxisSizing) restore.primaryAxisSizing = savedPrimary
if (updates.counterAxisSizing) restore.counterAxisSizing = savedCounter
if (Object.keys(restore).length > 0) graph.updateNode(child.id, restore)
}
function freeYogaTree(node: YogaNode): void {
for (let i = node.getChildCount() - 1; i >= 0; i--) {
freeYogaTree(node.getChild(i))
}
if ('free' in node) (node as { free(): void }).free()
}
function mapJustify(align: string): Justify {
switch (align) {
case 'CENTER':
return Justify.Center
case 'MAX':
return Justify.FlexEnd
case 'SPACE_BETWEEN':
return Justify.SpaceBetween
default:
return Justify.FlexStart
}
}
function mapAlign(align: string): Align {
switch (align) {
case 'CENTER':
return Align.Center
case 'MAX':
return Align.FlexEnd
case 'STRETCH':
return Align.Stretch
case 'BASELINE':
return Align.Baseline
default:
return Align.FlexStart
}
}
function mapAlignSelf(alignSelf: string): Align | null {
switch (alignSelf) {
case 'MIN':
return Align.FlexStart
case 'CENTER':
return Align.Center
case 'MAX':
return Align.FlexEnd
case 'STRETCH':
return Align.Stretch
case 'BASELINE':
return Align.Baseline
default:
return null
}
}