packages/core/src/lib/layout/flow.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import { preparedStyleValue, preparedStyleQuery, type PreparedQuery } from '../style/responsive.js';
2 import { definePrimitive } from '../define-primitive.js';
3 import { clonePibblElement } from '../element/create-element.js';
4 import { getChildPrimitiveReceiverToken } from '../element/metadata.js';
5 import type { PibblElement } from '../element/types.js';
6 import { GLOBAL_STATE } from '../global-state.js';
7 import { layoutDiagnostic } from '../style/diagnostics.js';
8 import {
9 type PibblStructuralEventProps,
10 wireStructuralEvents,
11 } from '../components/structural-events.js';
12 import { normalizeEdges } from '../style/normalize.js';
13 import { setPreNormalizedStyle } from '../style/resolve-dispatch.js';
14 import type { BoxStyle, LayoutItemStyle, ResolvedBoxStyle } from '../style/types.js';
15 import type {
16 CanvasMeasurementService,
17 PibblNodeInput,
18 MeasureInput,
19 MeasureResult,
20 RenderingContext2D,
21 SystemStyle,
22 } from '../types.js';
23 import {
24 childWithUsedSize,
25 materializeLayoutChildren,
26 prepareDirectChildStyle,
27 placeDirectChildren,
28 prepareLayoutChildren,
29 resolveContainerBox,
30 resolveDirectChildBox,
31 } from './absolute.js';
32 import { pushLayoutBox } from './context.js';
33 import { recordLayoutEvaluation } from './diagnostics.js';
34 import { assertSupportedChildPosition } from './positioning-diagnostics.js';
35 import { measureElement } from './measure.js';
36 import type { Constraints, LayoutBox } from './types.js';
37
38 type FlowDirection = 'horizontal' | 'vertical';
39 type FlowAlignment = 'start' | 'center' | 'end' | 'stretch';
40
41 /**
42 * Supported geometry and presentation properties for Flow.
43 *
44 * @see {@link BoxStyle}
45 * @see {@link LayoutItemStyle}
46 * @see {@link Flow}
47 */
48 export interface FlowStyle extends BoxStyle, LayoutItemStyle {
49 /** Direction in which this operation proceeds. See {@link FlowDirection}. */
50 direction?: FlowDirection;
51 /** Whether content may wrap into additional lines. See {@link FlowStyle}. */
52 wrap?: boolean;
53 /** Spacing between adjacent items. See {@link FlowStyle}. */
54 gap?: number;
55 /** Space between wrapped flow lines. See {@link FlowStyle}. */
56 lineGap?: number;
57 /** Default alignment of children on the cross or block axis. See {@link FlowAlignment}. */
58 alignItems?: FlowAlignment;
59 }
60
61 /**
62 * Authored inputs for Flow, including the declared data and presentation options.
63 *
64 * @see {@link PibblNodeInput}
65 * @see {@link Flow}
66 */
67 export interface FlowProps extends PibblStructuralEventProps {
68 /** Descendant content or the callback that supplies it. See {@link PibblNodeInput}. */
69 children: PibblNodeInput;
70 }
71
72 interface ResolvedFlowStyle extends SystemStyle {
73 box: ResolvedBoxStyle;
74 overflow: 'visible' | 'clip';
75 direction: FlowDirection;
76 wrap: boolean;
77 gap: number;
78 lineGap: number;
79 alignItems: FlowAlignment;
80 }
81
82 interface FlowItem {
83 child: PibblElement<any>;
84 normalized: Readonly<SystemStyle>;
85 width: number;
86 height: number;
87 horizontalPadding: number;
88 verticalPadding: number;
89 alignment: FlowAlignment;
90 receiverToken: object;
91 query?: PreparedQuery;
92 }
93
94 export interface FlowMeasurementInput {
95 service: CanvasMeasurementService;
96 measureElement(
97 element: PibblElement<any>,
98 constraints: Readonly<Constraints>,
99 ): MeasureResult;
100 }
101
102 interface FlowPlacement {
103 box: LayoutBox;
104 element: PibblElement<any>;
105 }
106
107 function renderFlow(
108 props: FlowProps,
109 style: Readonly<ResolvedFlowStyle>,
110 ctx: RenderingContext2D,
111 ) {
112 wireStructuralEvents(props);
113 const children = prepareLayoutChildren(props.children, 'Flow');
114 recordLayoutEvaluation();
115 const content = style.box.contentBox;
116 ctx.translate(content.x, content.y);
117 const localContent = Object.freeze({
118 x: 0,
119 y: 0,
120 width: content.width,
121 height: content.height,
122 });
123 const releaseLayout = pushLayoutBox(localContent);
124 GLOBAL_STATE.componentRefs!.onAfterRender = releaseLayout;
125 const service = {
126 measureText(value: string, font: string) {
127 ctx.save();
128 try {
129 ctx.font = font;
130 return ctx.measureText(value);
131 } finally {
132 ctx.restore();
133 }
134 },
135 };
136 const placements = resolveFlowPlacements(
137 localContent,
138 children,
139 style,
140 {
141 service,
142 measureElement: (element, constraints) =>
143 measureElement(element, constraints, service),
144 },
145 );
146 let index = 0;
147 return placeDirectChildren(
148 children,
149 localContent,
150 () => placements[index++],
151 style.overflow,
152 );
153 }
154
155 /**
156 * Describes sequential flow layout for JSX or createElement authoring.
157 *
158 * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
159 * props and style types.
160 * @throws When called directly; Pibbl mounts this component through JSX or createElement.
161 *
162 * @see {@link FlowProps}
163 * @see {@link FlowStyle}
164 */
165 export const Flow = definePrimitive<
166 FlowProps,
167 FlowStyle,
168 FlowStyle,
169 ResolvedFlowStyle
170 >(renderFlow, {
171 childInput: 'structural',
172 resolveStyle: (style, context) => ({
173 box: resolveContainerBox(style, context, 'Flow', 'flow'),
174 overflow: style.overflow ?? 'visible',
175 ...normalizeFlowOptions(style, context.allocation),
176 }),
177 measure: input => measureFlow(input),
178 });
179
180 export function layoutFlow(
181 container: Readonly<LayoutBox>,
182 children: PibblNodeInput,
183 style: Readonly<FlowStyle>,
184 measurement: FlowMeasurementInput,
185 ): LayoutBox[] {
186 const prepared = materializeLayoutChildren(children, 'Flow');
187 return resolveFlowPlacements(container, prepared, style, measurement)
188 .map(placement => placement.box);
189 }
190
191 function resolveFlowPlacements(
192 container: Readonly<LayoutBox>,
193 children: readonly PibblElement<any>[],
194 style: Readonly<FlowStyle>,
195 measurement: FlowMeasurementInput,
196 ): FlowPlacement[] {
197 assertFiniteContainer(container);
198 const options = normalizeFlowOptions(style, container);
199 const items: FlowItem[] = [];
200 for (const child of children) {
201 const receiverToken = getChildPrimitiveReceiverToken(
202 GLOBAL_STATE.renderTransaction,
203 GLOBAL_STATE.primitiveReceiverToken,
204 items.length,
205 );
206 const prepared = prepareDirectChildStyle(child, receiverToken, container);
207 const normalized = preparedStyleValue(prepared) as Readonly<
208 BoxStyle & LayoutItemStyle
209 >;
210 assertSupportedChildPosition('Flow', child, normalized);
211 const needsWidth = normalized.width === undefined || normalized.width === 'auto';
212 const needsHeight = normalized.height === undefined || normalized.height === 'auto';
213 let measuredWidth: number | undefined;
214 let measuredHeight: number | undefined;
215 if (needsWidth || needsHeight) {
216 const constraints = {
217 minWidth: 0,
218 maxWidth: container.width,
219 minHeight: 0,
220 maxHeight: container.height,
221 };
222 const measurementChild = clonePibblElement(child);
223 setPreNormalizedStyle(
224 measurementChild,
225 normalized,
226 child,
227 receiverToken,
228 preparedStyleQuery(prepared),
229 );
230 const result = measurement.measureElement(measurementChild, constraints);
231 if (result.status === 'unsupported') {
232 const axis = needsWidth ? 'width' : 'height';
233 throw layoutDiagnostic({
234 component: child.type.name || 'Anonymous',
235 property: axis,
236 value: normalized[axis] ?? 'auto',
237 algorithm: 'flow',
238 constraints,
239 reason: `measurement is required but unsupported: ${result.reason}`,
240 });
241 }
242 measuredWidth = result.size.width;
243 measuredHeight = result.size.height;
244 }
245 const resolvedStyle = {
246 ...normalized,
247 width: needsWidth ? measuredWidth : normalized.width,
248 height: needsHeight ? measuredHeight : normalized.height,
249 };
250 const resolved = resolveDirectChildBox(
251 container,
252 child,
253 'flow',
254 resolvedStyle,
255 );
256 const box = resolved.borderBox;
257 items.push({
258 child,
259 normalized: Object.freeze(resolvedStyle),
260 width: box.width,
261 height: box.height,
262 horizontalPadding: box.width - resolved.contentBox.width,
263 verticalPadding: box.height - resolved.contentBox.height,
264 alignment: resolveItemAlignment(
265 normalized.alignSelf,
266 options.alignItems,
267 child,
268 container,
269 ),
270 receiverToken,
271 query: preparedStyleQuery(prepared),
272 });
273 }
274
275 const boxes = arrangeFlowItems(container, items, options);
276 return boxes.map((box, index) => ({
277 box,
278 element: childWithUsedSize(
279 items[index].child,
280 items[index].normalized,
281 Math.max(0, box.width - items[index].horizontalPadding),
282 Math.max(0, box.height - items[index].verticalPadding),
283 items[index].receiverToken,
284 items[index].query,
285 ),
286 }));
287 }
288
289 function measureFlow(
290 input: MeasureInput<FlowProps, FlowStyle>,
291 ): MeasureResult {
292 const children = prepareLayoutChildren(input.props.children, 'Flow');
293 const widthDefinite = input.style.width !== undefined &&
294 input.style.width !== 'auto';
295 const heightDefinite = input.style.height !== undefined &&
296 input.style.height !== 'auto';
297 const provisional = provisionalFlowBox(input.style, input.constraints);
298 if (widthDefinite && heightDefinite) {
299 return { status: 'measured', size: {
300 width: provisional.content.width,
301 height: provisional.content.height,
302 } };
303 }
304
305 const placements = resolveFlowPlacements(
306 provisional.content,
307 children,
308 input.style,
309 input,
310 );
311 let intrinsicWidth = 0;
312 let intrinsicHeight = 0;
313 for (const placement of placements) {
314 intrinsicWidth = Math.max(
315 intrinsicWidth,
316 placement.box.x - provisional.content.x + placement.box.width,
317 );
318 intrinsicHeight = Math.max(
319 intrinsicHeight,
320 placement.box.y - provisional.content.y + placement.box.height,
321 );
322 }
323 validateMeasuredExtent(intrinsicWidth, 'width', input.constraints);
324 validateMeasuredExtent(intrinsicHeight, 'height', input.constraints);
325 return { status: 'measured', size: {
326 width: widthDefinite ? provisional.content.width : intrinsicWidth,
327 height: heightDefinite ? provisional.content.height : intrinsicHeight,
328 } };
329 }
330
331 function provisionalFlowBox(
332 style: Readonly<FlowStyle>,
333 constraints: Readonly<Constraints>,
334 ): { border: LayoutBox; content: LayoutBox } {
335 const diagnosticContext = {
336 component: 'Flow',
337 algorithm: 'measurement',
338 constraints,
339 };
340 const padding = normalizeEdges(style.padding, 'padding', diagnosticContext);
341 const width = finiteMeasurementBound(
342 constraints.maxWidth,
343 [style.width, style.minWidth, style.maxWidth],
344 padding.left + padding.right,
345 'width',
346 constraints,
347 );
348 const height = finiteMeasurementBound(
349 constraints.maxHeight,
350 [style.height, style.minHeight, style.maxHeight],
351 padding.top + padding.bottom,
352 'height',
353 constraints,
354 );
355 const box = resolveContainerBox(style, {
356 allocation: { x: 0, y: 0, width, height },
357 percentageBasis: { x: 0, y: 0, width, height },
358 constraints,
359 component: 'Flow',
360 algorithm: 'render-dispatch',
361 }, 'Flow', 'measurement');
362 return { border: box.borderBox, content: box.contentBox };
363 }
364
365 function finiteMeasurementBound(
366 maximum: number,
367 specified: readonly (BoxStyle['width'] | undefined)[],
368 padding: number,
369 property: 'width' | 'height',
370 constraints: Readonly<Constraints>,
371 ): number {
372 if (Number.isFinite(maximum)) return maximum;
373 const numeric = specified.filter(
374 (value): value is number => typeof value === 'number' && Number.isFinite(value),
375 );
376 if (numeric.length > 0) {
377 const bound = Math.max(...numeric) + padding;
378 if (Number.isFinite(bound)) return bound;
379 throw layoutDiagnostic({
380 component: 'Flow',
381 property,
382 value: bound,
383 algorithm: 'measurement',
384 constraints,
385 reason: 'measurement allocation arithmetic must remain finite',
386 });
387 }
388 const percentage = specified.find(
389 value => typeof value === 'string' && value.endsWith('%'),
390 );
391 if (percentage !== undefined) {
392 throw layoutDiagnostic({
393 component: 'Flow',
394 property,
395 value: percentage,
396 algorithm: 'measurement',
397 constraints,
398 reason: 'requires a definite finite percentage reference',
399 });
400 }
401 return Number.MAX_SAFE_INTEGER;
402 }
403
404 function validateMeasuredExtent(
405 value: number,
406 property: string,
407 constraints: Readonly<Constraints>,
408 ): void {
409 if (!Number.isFinite(value) || value < 0) {
410 throw layoutDiagnostic({
411 component: 'Flow',
412 property,
413 value,
414 algorithm: 'measurement',
415 constraints,
416 reason: 'intrinsic flow extent must be finite and nonnegative',
417 });
418 }
419 }
420
421 function arrangeFlowItems(
422 container: Readonly<LayoutBox>,
423 items: readonly FlowItem[],
424 options: ReturnType<typeof normalizeFlowOptions>,
425 ): LayoutBox[] {
426 const horizontal = options.direction === 'horizontal';
427 const mainBound = horizontal ? container.width : container.height;
428 const lines: FlowItem[][] = [];
429 let line: FlowItem[] = [];
430 let occupiedMain = 0;
431 for (const item of items) {
432 const mainSize = horizontal ? item.width : item.height;
433 const nextMain = line.length === 0 ?
434 mainSize :
435 occupiedMain + options.gap + mainSize;
436 if (options.wrap && line.length > 0 && nextMain > mainBound) {
437 lines.push(line);
438 line = [];
439 occupiedMain = 0;
440 }
441 occupiedMain = line.length === 0 ?
442 mainSize :
443 occupiedMain + options.gap + mainSize;
444 line.push(item);
445 }
446 if (line.length > 0) lines.push(line);
447
448 const placements: LayoutBox[] = [];
449 let crossCursor = 0;
450 for (const currentLine of lines) {
451 const lineCross = Math.max(0, ...currentLine.map(item =>
452 horizontal ? item.height : item.width,
453 ));
454 let mainCursor = 0;
455 for (const item of currentLine) {
456 const itemMain = horizontal ? item.width : item.height;
457 const itemCross = horizontal ? item.height : item.width;
458 const crossOffset = alignCross(lineCross, itemCross, item.alignment);
459 const placedCross = item.alignment === 'stretch' ?
460 Math.max(0, lineCross) :
461 itemCross;
462 placements.push(horizontal ? {
463 x: container.x + mainCursor,
464 y: container.y + crossCursor + crossOffset,
465 width: itemMain,
466 height: placedCross,
467 } : {
468 x: container.x + crossCursor + crossOffset,
469 y: container.y + mainCursor,
470 width: placedCross,
471 height: itemMain,
472 });
473 mainCursor += itemMain + options.gap;
474 }
475 crossCursor += lineCross + options.lineGap;
476 }
477 return placements;
478 }
479
480 function normalizeFlowOptions(
481 style: Readonly<FlowStyle>,
482 container: Readonly<LayoutBox>,
483 ) {
484 const direction = style.direction ?? 'horizontal';
485 if (direction !== 'horizontal' && direction !== 'vertical') {
486 throw optionDiagnostic('direction', direction, container);
487 }
488 if (style.wrap !== undefined && typeof style.wrap !== 'boolean') {
489 throw optionDiagnostic('wrap', style.wrap, container);
490 }
491 const alignItems = style.alignItems ?? 'start';
492 if (
493 alignItems !== 'start' && alignItems !== 'center' &&
494 alignItems !== 'end' && alignItems !== 'stretch'
495 ) {
496 throw optionDiagnostic('alignItems', alignItems, container);
497 }
498 return {
499 direction,
500 wrap: style.wrap ?? false,
501 gap: nonnegativeOption(style.gap ?? 0, 'gap', container),
502 lineGap: nonnegativeOption(style.lineGap ?? 0, 'lineGap', container),
503 alignItems,
504 };
505 }
506
507 function nonnegativeOption(
508 value: number,
509 property: string,
510 container: Readonly<LayoutBox>,
511 ): number {
512 if (!Number.isFinite(value) || value < 0) {
513 throw optionDiagnostic(property, value, container);
514 }
515 return value;
516 }
517
518 function resolveItemAlignment(
519 value: LayoutItemStyle['alignSelf'],
520 fallback: FlowAlignment,
521 child: PibblElement<any>,
522 container: Readonly<LayoutBox>,
523 ): FlowAlignment {
524 if (value === undefined || value === 'auto') return fallback;
525 if (value === 'start' || value === 'center' || value === 'end' || value === 'stretch') {
526 return value;
527 }
528 throw layoutDiagnostic({
529 component: child.type.name || 'Anonymous',
530 property: 'alignSelf',
531 value,
532 algorithm: 'flow',
533 constraints: constraintsFor(container),
534 reason: 'uses an unsupported flow alignment value',
535 });
536 }
537
538 function alignCross(
539 lineCross: number,
540 itemCross: number,
541 alignment: FlowAlignment,
542 ): number {
543 if (alignment === 'center') return (lineCross - itemCross) / 2;
544 if (alignment === 'end') return lineCross - itemCross;
545 return 0;
546 }
547
548 function assertFiniteContainer(container: Readonly<LayoutBox>): void {
549 for (const [property, value] of Object.entries(container)) {
550 if (!Number.isFinite(value) ||
551 ((property === 'width' || property === 'height') && value < 0)) {
552 throw optionDiagnostic(property, value, container);
553 }
554 }
555 }
556
557 function optionDiagnostic(
558 property: string,
559 value: unknown,
560 container: Readonly<LayoutBox>,
561 ) {
562 return layoutDiagnostic({
563 component: 'Flow',
564 property,
565 value,
566 algorithm: 'flow',
567 constraints: constraintsFor(container),
568 reason: 'uses an unsupported flow value',
569 });
570 }
571
572 function constraintsFor(container: Readonly<LayoutBox>) {
573 return {
574 minWidth: 0,
575 maxWidth: container.width,
576 minHeight: 0,
577 maxHeight: container.height,
578 };
579 }
580
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.