Skip to content

packages/core/src/lib/layout/flow.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

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 built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.