UNPKG

uicore-ts

Version:

UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework tha

1,378 lines (1,074 loc) 48.1 kB
import { FIRST_OR_NIL, IS, IS_DEFINED, IS_NIL, IS_NOT_LIKE_NULL, IS_NOT_NIL, nil, NO, UIObject, YES } from "./UIObject" import { UIPoint } from "./UIPoint" import { UIView } from "./UIView" export type SizeNumberOrFunctionOrView = number | ((constrainingOrthogonalSize: number) => number) | UIView export class UIRectangle extends UIObject { _isBeingUpdated: boolean rectanglePointDidChange?: (b: any) => void // COW: Internal data structure that can be shared private _data: { min: UIPoint max: UIPoint minHeight?: number maxHeight?: number minWidth?: number maxWidth?: number refCount: number } // COW: Flag to indicate this is a lazy copy private _isLazyCopy: boolean constructor(x: number = 0, y: number = 0, height: number = 0, width: number = 0) { super() this._isLazyCopy = NO this._isBeingUpdated = NO // COW: Create the shared data structure this._data = { min: new UIPoint(x, y), max: new UIPoint(x + width, y + height), refCount: 1 } this._setupPointCallbacks() if (IS_NIL(height)) { this._data.max.y = height } if (IS_NIL(width)) { this._data.max.x = width } } // COW: Setup callbacks for point changes private _setupPointCallbacks() { this._data.min.didChange = (point) => { this.rectanglePointDidChange?.(point) this._rectanglePointDidChange() } this._data.max.didChange = (point) => { this.rectanglePointDidChange?.(point) this._rectanglePointDidChange() } } // COW: Materialize a lazy copy before mutation materialize() { if (this._isLazyCopy || this._data.refCount > 1) { this._data.refCount-- const oldData = this._data this._data = { min: oldData.min.copy(), max: oldData.max.copy(), minHeight: oldData.minHeight, maxHeight: oldData.maxHeight, minWidth: oldData.minWidth, maxWidth: oldData.maxWidth, refCount: 1 } this._setupPointCallbacks() this._isLazyCopy = NO } } // Copy on write: Lazy copy that shares data // Tested to reduce CPU time from 2.2% to 1% during heavy resizing lazyCopy(): UIRectangle { const result = Object.create(UIRectangle.prototype) result._data = this._data result._data.refCount++ result._isLazyCopy = YES result._isBeingUpdated = NO result.rectanglePointDidChange = this.rectanglePointDidChange return result } // COW: Getters and setters that materialize on write get min(): UIPoint { return this._data.min } set min(value: UIPoint) { this.materialize() this._data.min = value this._setupPointCallbacks() } get max(): UIPoint { return this._data.max } set max(value: UIPoint) { this.materialize() this._data.max = value this._setupPointCallbacks() } get minHeight(): number | undefined { return this._data.minHeight } set minHeight(value: number | undefined) { this.materialize() this._data.minHeight = value } get maxHeight(): number | undefined { return this._data.maxHeight } set maxHeight(value: number | undefined) { this.materialize() this._data.maxHeight = value } get minWidth(): number | undefined { return this._data.minWidth } set minWidth(value: number | undefined) { this.materialize() this._data.minWidth = value } get maxWidth(): number | undefined { return this._data.maxWidth } set maxWidth(value: number | undefined) { this.materialize() this._data.maxWidth = value } copy() { const result = new UIRectangle(this.x, this.y, this.height, this.width) result.minHeight = this.minHeight result.minWidth = this.minWidth result.maxHeight = this.maxHeight result.maxWidth = this.maxWidth return result } isEqualTo(rectangle: UIRectangle | null | undefined) { return (IS(rectangle) && this.min.isEqualTo(rectangle.min) && this.max.isEqualTo(rectangle.max)) } static zero() { return new UIRectangle(0, 0, 0, 0) } containsPoint(point: UIPoint) { return this.min.x <= point.x && this.min.y <= point.y && point.x <= this.max.x && point.y <= this.max.y } updateByAddingPoint(point: UIPoint) { this.materialize() // COW: Materialize before mutation if (!point) { point = new UIPoint(0, 0) } this.beginUpdates() const min = this.min.copy() if (min.x === nil) { min.x = this.max.x } if (min.y === nil) { min.y = this.max.y } const max = this.max.copy() if (max.x === nil) { max.x = this.min.x } if (max.y === nil) { max.y = this.min.y } this.min.x = Math.min(min.x, point.x) this.min.y = Math.min(min.y, point.y) this.max.x = Math.max(max.x, point.x) this.max.y = Math.max(max.y, point.y) this.finishUpdates() } scale(scale: number) { this.materialize() // COW: Materialize before mutation if (IS_NOT_NIL(this.max.y)) { this.height = this.height * scale } if (IS_NOT_NIL(this.max.x)) { this.width = this.width * scale } } get height() { if (this.max.y === nil) { return nil } return this.max.y - this.min.y } set height(height: number) { this.materialize() // COW: Materialize before mutation this._data.max.y = this.min.y + height } get width() { if (this.max.x === nil) { return nil } return this.max.x - this.min.x } set width(width: number) { this.materialize() // COW: Materialize before mutation this._data.max.x = this.min.x + width } get x() { return this.min.x } set x(x: number) { this.materialize() // COW: Materialize before mutation this.beginUpdates() const width = this.width this._data.min.x = x this._data.max.x = this.min.x + width this.finishUpdates() } get y() { return this.min.y } set y(y: number) { this.materialize() // COW: Materialize before mutation this.beginUpdates() const height = this.height this._data.min.y = y this._data.max.y = this.min.y + height this.finishUpdates() } get topLeft() { return this.min.copy() } get topRight() { return new UIPoint(this.max.x, this.y) } get bottomLeft() { return new UIPoint(this.x, this.max.y) } get bottomRight() { return this.max.copy() } get center() { return this.min.copy().add(this.min.to(this.max).scale(0.5)) } set center(center: UIPoint) { this.materialize() // COW: Materialize before mutation const offset = this.center.to(center) this.offsetByPoint(offset) } offsetByPoint(offset: UIPoint) { this.materialize() // COW: Materialize before mutation this.min.add(offset) this.max.add(offset) return this } concatenateWithRectangle(rectangle: UIRectangle) { this.updateByAddingPoint(rectangle.bottomRight) this.updateByAddingPoint(rectangle.topLeft) return this } rectangleByConcatenatingWithRectangle(rectangle: UIRectangle) { return this.lazyCopy().concatenateWithRectangle(rectangle) // COW: Use lazyCopy } intersectionRectangleWithRectangle(rectangle: UIRectangle): UIRectangle { const result = this.lazyCopy() // COW: Use lazyCopy result.materialize() // COW: We're going to modify it result.beginUpdates() const min = result.min if (min.x === nil) { min.x = rectangle.max.x - Math.min(result.width, rectangle.width) } if (min.y === nil) { min.y = rectangle.max.y - Math.min(result.height, rectangle.height) } const max = result.max if (max.x === nil) { max.x = rectangle.min.x + Math.min(result.width, rectangle.width) } if (max.y === nil) { max.y = rectangle.min.y + Math.min(result.height, rectangle.height) } result.min.x = Math.max(result.min.x, rectangle.min.x) result.min.y = Math.max(result.min.y, rectangle.min.y) result.max.x = Math.min(result.max.x, rectangle.max.x) result.max.y = Math.min(result.max.y, rectangle.max.y) if (result.height < 0) { const averageY = (this.center.y + rectangle.center.y) * 0.5 result.min.y = averageY result.max.y = averageY } if (result.width < 0) { const averageX = (this.center.x + rectangle.center.x) * 0.5 result.min.x = averageX result.max.x = averageX } result.finishUpdates() return result } get area() { return this.height * this.width } intersectsWithRectangle(rectangle: UIRectangle) { return (this.intersectionRectangleWithRectangle(rectangle).area != 0) } // add some space around the rectangle rectangleWithInsets(left: number, right: number, bottom: number, top: number) { const result = this.lazyCopy() // COW: Use lazyCopy result.materialize() // COW: We're modifying multiple properties result.min.x = this.min.x + left result.max.x = this.max.x - right result.min.y = this.min.y + top result.max.y = this.max.y - bottom return result } rectangleWithInset(inset: number) { return this.rectangleWithInsets(inset, inset, inset, inset) } rectangleWithHeight(height: SizeNumberOrFunctionOrView, centeredOnPosition: number = nil) { height = this._heightNumberFromSizeNumberOrFunctionOrView(height) if (isNaN(centeredOnPosition)) { centeredOnPosition = nil } const result = this.lazyCopy() // COW: Use lazyCopy result.height = height if (centeredOnPosition != nil) { const change = height - this.height result.offsetByPoint(new UIPoint(0, change * centeredOnPosition).scale(-1)) } return result } rectangleWithWidth(width: SizeNumberOrFunctionOrView, centeredOnPosition: number = nil) { width = this._widthNumberFromSizeNumberOrFunctionOrView(width) if (isNaN(centeredOnPosition)) { centeredOnPosition = nil } const result = this.lazyCopy() // COW: Use lazyCopy result.width = width if (centeredOnPosition != nil) { const change = width - this.width result.offsetByPoint(new UIPoint(change * centeredOnPosition, 0).scale(-1)) } return result } rectangleWithHeightRelativeToWidth(heightRatio: number = 1, centeredOnPosition: number = nil) { return this.rectangleWithHeight(this.width * heightRatio, centeredOnPosition) } rectangleWithWidthRelativeToHeight(widthRatio: number = 1, centeredOnPosition: number = nil) { return this.rectangleWithWidth(this.height * widthRatio, centeredOnPosition) } rectangleWithX(x: number, centeredOnPosition: number = 0) { const result = this.lazyCopy() // COW: Use lazyCopy result.x = x - result.width * centeredOnPosition return result } rectangleWithY(y: number, centeredOnPosition: number = 0) { const result = this.lazyCopy() // COW: Use lazyCopy result.y = y - result.height * centeredOnPosition return result } rectangleByAddingX(x: number) { const result = this.lazyCopy() // COW: Use lazyCopy result.x = this.x + x return result } rectangleByAddingY(y: number) { const result = this.lazyCopy() // COW: Use lazyCopy result.y = this.y + y return result } rectangleByAddingWidth(widthToAdd: number, centeredOnPosition = 0) { const result = this.rectangleWithWidth(this.width + widthToAdd, centeredOnPosition) return result } rectangleByAddingHeight(heightToAdd: number, centeredOnPosition = 0) { const result = this.rectangleWithHeight(this.height + heightToAdd, centeredOnPosition) return result } rectangleWithRelativeValues( relativeXPosition: number, widthMultiplier: number, relativeYPosition: number, heightMultiplier: number ) { const result = this.lazyCopy() // COW: Use lazyCopy result.materialize() // COW: We're modifying multiple properties const width = result.width const height = result.height result.width = widthMultiplier * width result.height = heightMultiplier * height result.center = new UIPoint( relativeXPosition * width, relativeYPosition * height ) return result } /** * Returns a rectangle with a maximum width constraint * If current width exceeds max, centers the constrained width */ rectangleWithMaxWidth(maxWidth: number, centeredOnPosition: number = 0): UIRectangle { if (this.width <= maxWidth) { return this.lazyCopy() // COW: Use lazyCopy } return this.rectangleWithWidth(maxWidth, centeredOnPosition) } /** * Returns a rectangle with a maximum height constraint */ rectangleWithMaxHeight(maxHeight: number, centeredOnPosition: number = 0): UIRectangle { if (this.height <= maxHeight) { return this.lazyCopy() // COW: Use lazyCopy } return this.rectangleWithHeight(maxHeight, centeredOnPosition) } /** * Returns a rectangle with minimum width constraint */ rectangleWithMinWidth(minWidth: number, centeredOnPosition: number = 0): UIRectangle { if (this.width >= minWidth) { return this.lazyCopy() // COW: Use lazyCopy } return this.rectangleWithWidth(minWidth, centeredOnPosition) } /** * Returns a rectangle with minimum height constraint */ rectangleWithMinHeight(minHeight: number, centeredOnPosition: number = 0): UIRectangle { if (this.height >= minHeight) { return this.lazyCopy() // COW: Use lazyCopy } return this.rectangleWithHeight(minHeight, centeredOnPosition) } // Returns a new rectangle that is positioned relative to the reference rectangle // By default, it makes a copy of this rectangle taht is centered in the target rectangle rectangleByCenteringInRectangle(referenceRectangle: UIRectangle, xPosition = 0.5, yPosition = 0.5) { const result = this.lazyCopy() // COW: Use lazyCopy result.center = referenceRectangle.topLeft .pointByAddingX(xPosition * referenceRectangle.width) .pointByAddingY(yPosition * referenceRectangle.height) return result } rectanglesBySplittingWidth( weights: SizeNumberOrFunctionOrView[], paddings: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 0, absoluteWidths: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = nil ) { if (IS_NIL(paddings)) { paddings = 1 } if (!((paddings as any) instanceof Array)) { paddings = [paddings].arrayByRepeating(weights.length - 1) } paddings = (paddings as any[]).arrayByTrimmingToLengthIfLonger(weights.length - 1) paddings = paddings.map(padding => this._widthNumberFromSizeNumberOrFunctionOrView(padding)) if (!(absoluteWidths instanceof Array) && IS_NOT_NIL(absoluteWidths)) { absoluteWidths = [absoluteWidths].arrayByRepeating(weights.length) } absoluteWidths = absoluteWidths.map( width => this._widthNumberFromSizeNumberOrFunctionOrView(width) ) weights = weights.map(weight => this._widthNumberFromSizeNumberOrFunctionOrView(weight)) const result: UIRectangle[] = [] const sumOfWeights = (weights as number[]).reduce( (a, b, index) => { if (IS_NOT_NIL((absoluteWidths as number[])[index])) { b = 0 } return a + b }, 0 ) const sumOfPaddings = paddings.summedValue as number const sumOfAbsoluteWidths = (absoluteWidths as number[]).summedValue const totalRelativeWidth = this.width - sumOfPaddings - sumOfAbsoluteWidths let previousCellMaxX = this.x for (let i = 0; i < weights.length; i++) { let resultWidth: number if (IS_NOT_NIL(absoluteWidths[i])) { resultWidth = (absoluteWidths[i] || 0) as number } else { resultWidth = totalRelativeWidth * (weights[i] as number / sumOfWeights) } const rectangle = this.rectangleWithWidth(resultWidth) let padding = 0 if (paddings.length > i && paddings[i]) { padding = paddings[i] as number } rectangle.x = previousCellMaxX previousCellMaxX = rectangle.max.x + padding result.push(rectangle) } return result } rectanglesBySplittingHeight( weights: SizeNumberOrFunctionOrView[], paddings: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 0, absoluteHeights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = nil ) { if (IS_NIL(paddings)) { paddings = 1 } if (!((paddings as any) instanceof Array)) { paddings = [paddings].arrayByRepeating(weights.length - 1) } paddings = (paddings as number[]).arrayByTrimmingToLengthIfLonger(weights.length - 1) paddings = paddings.map(padding => this._heightNumberFromSizeNumberOrFunctionOrView(padding)) if (!(absoluteHeights instanceof Array) && IS_NOT_NIL(absoluteHeights)) { absoluteHeights = [absoluteHeights].arrayByRepeating(weights.length) } absoluteHeights = absoluteHeights.map( height => this._heightNumberFromSizeNumberOrFunctionOrView(height) ) weights = weights.map(weight => this._heightNumberFromSizeNumberOrFunctionOrView(weight)) const result: UIRectangle[] = [] const sumOfWeights = (weights as number[]).reduce( (a, b, index) => { if (IS_NOT_NIL((absoluteHeights as number[])[index])) { b = 0 } return a + b }, 0 ) const sumOfPaddings = paddings.summedValue as number const sumOfAbsoluteHeights = (absoluteHeights as number[]).summedValue const totalRelativeHeight = this.height - sumOfPaddings - sumOfAbsoluteHeights let previousCellMaxY = this.y for (let i = 0; i < weights.length; i++) { let resultHeight: number if (IS_NOT_NIL(absoluteHeights[i])) { resultHeight = (absoluteHeights[i] || 0) as number } else { resultHeight = totalRelativeHeight * (weights[i] as number / sumOfWeights) } const rectangle = this.rectangleWithHeight(resultHeight) let padding = 0 if (paddings.length > i && paddings[i]) { padding = paddings[i] as number } rectangle.y = previousCellMaxY previousCellMaxY = rectangle.max.y + padding //rectangle = rectangle.rectangleWithInsets(0, 0, padding, 0); result.push(rectangle) } return result } rectanglesByEquallySplittingWidth(numberOfFrames: number, padding: number = 0) { const result: UIRectangle[] = [] const totalPadding = padding * (numberOfFrames - 1) const resultWidth = (this.width - totalPadding) / numberOfFrames for (var i = 0; i < numberOfFrames; i++) { const rectangle = this.rectangleWithWidth(resultWidth, i / (numberOfFrames - 1)) result.push(rectangle) } return result } rectanglesByEquallySplittingHeight(numberOfFrames: number, padding: number = 0) { const result: UIRectangle[] = [] const totalPadding = padding * (numberOfFrames - 1) const resultHeight = (this.height - totalPadding) / numberOfFrames for (var i = 0; i < numberOfFrames; i++) { const rectangle = this.rectangleWithHeight(resultHeight, i / (numberOfFrames - 1)) result.push(rectangle) } return result } distributeViewsAlongWidth( views: UIView[], weights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 1, paddings?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[], absoluteWidths?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] ) { if (!(weights instanceof Array)) { weights = [weights].arrayByRepeating(views.length) } const frames = this.rectanglesBySplittingWidth(weights, paddings, absoluteWidths) frames.forEach((frame, index) => FIRST_OR_NIL(views[index]).frame = frame) return this } distributeViewsAlongHeight( views: UIView[], weights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 1, paddings?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[], absoluteHeights?: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] ) { if (!(weights instanceof Array)) { weights = [weights].arrayByRepeating(views.length) } const frames = this.rectanglesBySplittingHeight(weights, paddings, absoluteHeights) frames.forEach((frame, index) => FIRST_OR_NIL(views[index]).frame = frame) return this } distributeViewsEquallyAlongWidth(views: UIView[], padding: number) { const frames = this.rectanglesByEquallySplittingWidth(views.length, padding) frames.forEach((frame, index) => views[index].frame = frame) return this } distributeViewsEquallyAlongHeight(views: UIView[], padding: number) { const frames = this.rectanglesByEquallySplittingHeight(views.length, padding) frames.forEach((frame, index) => views[index].frame = frame) return this } _heightNumberFromSizeNumberOrFunctionOrView(height: SizeNumberOrFunctionOrView) { if (height instanceof Function) { return height(this.width) } if (height instanceof UIView) { return height.intrinsicContentHeight(this.width) } return height } _widthNumberFromSizeNumberOrFunctionOrView(width: SizeNumberOrFunctionOrView) { if (width instanceof Function) { return width(this.height) } if (width instanceof UIView) { return width.intrinsicContentWidth(this.height) } return width } rectangleForNextRow(padding: number = 0, height: SizeNumberOrFunctionOrView = this.height) { const heightNumber = this._heightNumberFromSizeNumberOrFunctionOrView(height) const result = this.rectangleWithY(this.max.y + padding) if (heightNumber != this.height) { result.height = heightNumber } return result } rectangleForNextColumn(padding: number = 0, width: SizeNumberOrFunctionOrView = this.width) { const widthNumber = this._widthNumberFromSizeNumberOrFunctionOrView(width) const result = this.rectangleWithX(this.max.x + padding) if (widthNumber != this.width) { result.width = widthNumber } return result } rectangleForPreviousRow(padding: number = 0, height: SizeNumberOrFunctionOrView = this.height) { const heightNumber = this._heightNumberFromSizeNumberOrFunctionOrView(height) const result = this.rectangleWithY(this.min.y - heightNumber - padding) if (heightNumber != this.height) { result.height = heightNumber } return result } rectangleForPreviousColumn(padding: number = 0, width: SizeNumberOrFunctionOrView = this.width) { const widthNumber = this._widthNumberFromSizeNumberOrFunctionOrView(width) const result = this.rectangleWithX(this.min.x - widthNumber - padding) if (widthNumber != this.width) { result.width = widthNumber } return result } /** * Distributes views vertically as a column, assigning frames and returning them. * Each view is positioned below the previous one with optional padding between them. * @param views - Array of views to distribute * @param paddings - Padding between views (single value or array of values) * @param absoluteHeights - Optional fixed heights for views (overrides intrinsic height) * @returns Array of rectangles representing the frame for each view */ framesByDistributingViewsAsColumn( views: UIView[], paddings: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 0, absoluteHeights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = nil ) { const frames: UIRectangle[] = [] let currentRectangle = this.lazyCopy() // COW: Use lazyCopy if (!(paddings instanceof Array)) { paddings = [paddings].arrayByRepeating(views.length - 1) } paddings = paddings.map(padding => this._heightNumberFromSizeNumberOrFunctionOrView(padding)) if (!(absoluteHeights instanceof Array) && IS_NOT_NIL(absoluteHeights)) { absoluteHeights = [absoluteHeights].arrayByRepeating(views.length) } absoluteHeights = absoluteHeights.map( height => this._heightNumberFromSizeNumberOrFunctionOrView(height) ) for (let i = 0; i < views.length; i++) { const frame = currentRectangle.rectangleWithHeight(views[i]) if (IS_NOT_NIL(absoluteHeights[i])) { frame.height = absoluteHeights[i] as number } views[i].frame = frame frames.push(frame) const padding = (paddings[i] || 0) as number currentRectangle = frame.rectangleForNextRow(padding) } return frames } /** * Distributes views horizontally as a row, assigning frames and returning them. * Each view is positioned to the right of the previous one with optional padding between them. * @param views - Array of views to distribute * @param paddings - Padding between views (single value or array of values) * @param absoluteWidths - Optional fixed widths for views (overrides intrinsic width) * @param centeredOnPosition - Horizontal alignment of the row within this rectangle: 0 = left (default), 0.5 = * center, 1 = right * @returns Array of rectangles representing the frame for each view */ framesByDistributingViewsAsRow( views: UIView[], paddings: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 0, absoluteWidths: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = nil, centeredOnPosition = 0 ) { const frames: UIRectangle[] = [] let currentRectangle = this.lazyCopy() // COW: Use lazyCopy if (!(paddings instanceof Array)) { paddings = [paddings].arrayByRepeating(views.length - 1) } paddings = paddings.map(padding => this._widthNumberFromSizeNumberOrFunctionOrView(padding)) if (!(absoluteWidths instanceof Array) && IS_NOT_NIL(absoluteWidths)) { absoluteWidths = [absoluteWidths].arrayByRepeating(views.length) } absoluteWidths = absoluteWidths.map( width => this._widthNumberFromSizeNumberOrFunctionOrView(width) ) for (let i = 0; i < views.length; i++) { const frame = currentRectangle.rectangleWithWidth(views[i]) if (IS_NOT_NIL(absoluteWidths[i])) { frame.width = absoluteWidths[i] as number } frames.push(frame) const padding = (paddings[i] || 0) as number currentRectangle = frame.rectangleForNextColumn(padding) } if (centeredOnPosition !== 0 && frames.length > 0) { const rowWidth = frames.lastElement.max.x - frames.firstElement.x const offset = (this.width - rowWidth) * centeredOnPosition - (frames.firstElement.x - this.x) frames.forEach(frame => { frame.x += offset }) } frames.forEach((frame, index) => views[index].frame = frame) return frames } /** * Distributes views as a grid (2D array), assigning frames and returning them. * The first index represents rows (vertical), the second index represents columns (horizontal). * Example: views[0] is the first row, views[0][0] is the first column in the first row. * Each row is laid out horizontally, and rows are stacked vertically. * @param views - 2D array where views[row][column] represents the grid structure * @param paddings - Vertical padding between rows (single value or array of values) * @param absoluteHeights - Optional fixed heights for each row (overrides intrinsic height) * @returns 2D array of rectangles where frames[row][column] matches views[row][column] */ framesByDistributingViewsAsGrid( views: UIView[][], paddings: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = 0, absoluteHeights: SizeNumberOrFunctionOrView | SizeNumberOrFunctionOrView[] = nil ) { const frames: UIRectangle[][] = [] let currentRowRectangle = this.lazyCopy() // COW: Use lazyCopy if (!(paddings instanceof Array)) { paddings = [paddings].arrayByRepeating(views.length - 1) } paddings = paddings.map(padding => this._heightNumberFromSizeNumberOrFunctionOrView(padding)) if (!(absoluteHeights instanceof Array) && IS_NOT_NIL(absoluteHeights)) { absoluteHeights = [absoluteHeights].arrayByRepeating(views.length) } absoluteHeights = absoluteHeights.map( height => this._heightNumberFromSizeNumberOrFunctionOrView(height) ) for (let i = 0; i < views.length; i++) { const rowViews = views[i] const rowFrames = currentRowRectangle.framesByDistributingViewsAsRow(rowViews) if (IS_NOT_NIL(absoluteHeights[i])) { const heightNumber = absoluteHeights[i] as number rowFrames.forEach((frame, j) => { frame.height = heightNumber rowViews[j].frame = frame }) } frames.push(rowFrames) const padding = (paddings[i] || 0) as number const maxHeight = Math.max(...rowFrames.map(f => f.height)) currentRowRectangle = currentRowRectangle.rectangleForNextRow(padding, maxHeight) } return frames } rectangleWithIntrinsicContentSizeForView(view: UIView, centeredOnXPosition = 0, centeredOnYPosition = 0) { const intrinsicContentSize = view.intrinsicContentSize() return this.rectangleWithHeight(intrinsicContentSize.height, centeredOnYPosition) .rectangleWithWidth(intrinsicContentSize.width, centeredOnXPosition) } settingMinHeight(minHeight?: number) { this.minHeight = minHeight return this } settingMinWidth(minWidth?: number) { this.minWidth = minWidth return this } settingMaxHeight(maxHeight?: number) { this.maxHeight = maxHeight return this } settingMaxWidth(maxWidth?: number) { this.maxWidth = maxWidth return this } rectangleByEnforcingMinAndMaxSizes(centeredOnXPosition = 0, centeredOnYPosition = 0) { return this.rectangleWithHeight( [ [this.height, this.maxHeight].filter(value => IS_NOT_LIKE_NULL(value)).min(), this.minHeight ].filter(value => IS_NOT_LIKE_NULL(value)).max(), centeredOnYPosition ).rectangleWithWidth( [ [this.width, this.maxWidth].filter(value => IS_NOT_LIKE_NULL(value)).min(), this.minWidth ].filter(value => IS_NOT_LIKE_NULL(value)).max(), centeredOnXPosition ) } assignedAsFrameOfView(view: UIView, isWeakFrame = view.hasWeakFrame) { view.frame = this view.hasWeakFrame = isWeakFrame return this } override toString() { const result = "[" + this.class.name + "] { x: " + this.x + ", y: " + this.y + ", " + "height: " + this.height.toFixed(2) + ", width: " + this.height.toFixed(2) + " }" return result } get [Symbol.toStringTag]() { return this.toString() } IF(condition: boolean): UIRectangleConditionalChain<UIRectangle> { const conditionalBlock = new UIRectangleConditionalBlock(this, condition) // @ts-ignore return conditionalBlock.getProxy() } // These will be intercepted by the proxy, but we define them for TypeScript ELSE_IF(condition: boolean): UIRectangle { return this } ELSE(): UIRectangle { return this } ENDIF(): this ENDIF<T, R>(performFunction: (result: T) => R): R ENDIF<T, R>(performFunction?: (result: T) => R): R | this { if (performFunction) { return performFunction(this as any) } return this } // Bounding box static boundingBoxForPoints(points: UIPoint[]) { if (points.length === 0) { return new UIRectangle() } const first = points[0] const result = new UIRectangle(first.x, first.y, 0, 0) for (let i = 1; i < points.length; i++) { result.updateByAddingPoint(points[i]) } return result } static boundingBoxForRectanglesAndPoints(rectanglesAndPoints: (UIPoint | UIRectangle)[]) { if (rectanglesAndPoints.length === 0) { return new UIRectangle() } const first = rectanglesAndPoints[0] const result = first instanceof UIRectangle ? new UIRectangle(first.x, first.y, first.height, first.width) : new UIRectangle(first.x, first.y, 0, 0) for (let i = 1; i < rectanglesAndPoints.length; i++) { const rectangleOrPoint = rectanglesAndPoints[i] if (rectangleOrPoint instanceof UIRectangle) { result.updateByAddingPoint(rectangleOrPoint.min) result.updateByAddingPoint(rectangleOrPoint.max) } else { result.updateByAddingPoint(rectangleOrPoint) } } return result } beginUpdates() { this._isBeingUpdated = YES } finishUpdates() { this._isBeingUpdated = NO this.didChange() } didChange() { // Callback to be set by delegate } _rectanglePointDidChange() { if (!this._isBeingUpdated) { this.didChange() } } } // 1. Methods available when holding a UIRectangle type RectangleChainMethods<TResult> = { [K in keyof UIRectangle as ( K extends 'IF' | 'ELSE' | 'ELSE_IF' | 'ENDIF' ? never : K )]: UIRectangle[K] extends (...args: infer Args) => infer R ? R extends UIRectangle | UIRectangle[] // CHANGE: We do NOT add 'R' to 'TResult' here. We only update the current state (R). ? (...args: Args) => UIRectangleConditionalChain<R, TResult> : never : never }; // 2. Methods available when holding a UIRectangle[] type ArrayChainMethods<TResult> = { [K in keyof UIRectangle[]]: UIRectangle[][K] extends UIRectangle ? UIRectangleConditionalChain<UIRectangle, TResult> // No accumulation for properties : UIRectangle[][K] extends (...args: infer Args) => infer R ? R extends UIRectangle | UIRectangle[] // CHANGE: We do NOT add 'R' to 'TResult' here either. ? (...args: Args) => UIRectangleConditionalChain<R, TResult> : never : never }; // 3. Methods available in both states (Control Flow + Transform) type SharedChainMethods<TCurrent, TResult> = { // IF opens a nested conditional block. After the matching ENDIF(), the chain resumes // as a UIRectangle — both at runtime (the proxy forwards to the current rectangle) and // at the type level. Nesting is supported to arbitrary depth. IF(condition: boolean): UIRectangleConditionalChain<UIRectangle, TResult>; // TRANSFORM applies an inline function and continues the chain. TRANSFORM<R extends UIRectangle>(fn: (current: TCurrent) => R): UIRectangleConditionalChain<R, TResult>; // ELSE_IF / ELSE reset the branch state. ELSE_IF(condition: boolean): UIRectangleConditionalChain<UIRectangle, TResult | TCurrent>; ELSE(): UIRectangleConditionalChain<UIRectangle, TResult | TCurrent>; // ENDIF closes this IF block. // No-arg: always typed as UIRectangle so rectangle methods are available immediately // after. At runtime the proxy wraps the current rectangle and forwards all calls. // With transform fn: returns R directly, escaping the chain entirely. ENDIF(): UIRectangle; ENDIF<R>(performFunction: (result: TResult | TCurrent) => R): R; }; // 4. The Main Type (No changes needed here, just re-stating for context) type UIRectangleConditionalChain<TCurrent, TResult = TCurrent> = (TCurrent extends UIRectangle ? RectangleChainMethods<TResult> : {}) & (TCurrent extends UIRectangle[] ? ArrayChainMethods<TResult> : {}) & SharedChainMethods<TCurrent, TResult>; interface UIRectangleConditionalFrame { // The result to resume from when this frame's ENDIF is reached (if no branch matched) resultBeforeIF: any // The result accumulated inside the active branch of this frame currentResult: any // The result at the point of IF — used to reset currentResult when entering each new branch originalResult: any // Whether any branch of this frame has already been taken (latches to true, never resets) anyConditionMet: boolean // Whether the current branch is the one that was taken (flips per ELSE_IF / ELSE) currentBranchActive: boolean } class UIRectangleConditionalBlock { // Stack of nested IF frames. The top of the stack is the innermost active IF. private _stack: UIRectangleConditionalFrame[] constructor(initialResult: UIRectangle, condition: boolean) { // Seed the stack with the first IF frame. // resultBeforeIF is null here because this is the outermost block; // ENDIF on the last frame simply returns currentResult. this._stack = [{ resultBeforeIF: null, currentResult: initialResult, originalResult: initialResult, anyConditionMet: condition, currentBranchActive: condition, }] } // Convenience getter that operates on the innermost frame. private get _top(): UIRectangleConditionalFrame { return this._stack[this._stack.length - 1] } // A method body should only execute when every frame in the stack has its // current branch active (handles nested IFs correctly). private get _shouldExecute(): boolean { return this._stack.every(frame => frame.currentBranchActive) } private createProxy(): UIRectangleConditionalChain<any, any> { const self = this return new Proxy({}, { get(_, prop) { // ── Control Flow ──────────────────────────────────────────────────── if (prop === 'IF') { return (condition: boolean) => { // Push a new frame. The new frame's result starts as a copy of // the current innermost result so that chaining inside the nested // IF begins from the right value. self._stack.push({ resultBeforeIF: self._top.currentResult, currentResult: self._top.currentResult, originalResult: self._top.currentResult, anyConditionMet: condition, currentBranchActive: condition, }) return self.createProxy() } } if (prop === 'TRANSFORM') { return <R extends UIRectangle>(fn: (current: any) => R) => { if (self._shouldExecute) { self._top.currentResult = fn(self._top.currentResult) } return self.createProxy() } } if (prop === 'ELSE_IF') { return (condition: boolean) => { const top = self._top // Only consider this branch if no prior branch has been taken. // Always deactivate the current branch first, then activate only // if this condition is true and nothing has matched yet. top.currentBranchActive = !top.anyConditionMet && condition if (top.currentBranchActive) { top.anyConditionMet = true top.currentResult = top.originalResult } return self.createProxy() } } if (prop === 'ELSE') { return () => { const top = self._top top.currentBranchActive = !top.anyConditionMet if (top.currentBranchActive) { top.anyConditionMet = true top.currentResult = top.originalResult } return self.createProxy() } } if (prop === 'ENDIF') { function endif(): any function endif<R>(performFunction: (result: any) => R): R function endif<R>(performFunction?: (result: any) => R): R | any { if (self._stack.length === 1) { // Outermost ENDIF. Return the bare rectangle (or transform it). const top = self._top const result = top.currentResult if (performFunction && top.anyConditionMet) { return performFunction(result) } else { return result } } // Pop the innermost (nested) frame. const completedFrame = self._stack.pop()! // If any branch was taken use its accumulated result; otherwise // fall back to the value that existed before entering this IF. const resolvedResult = completedFrame.anyConditionMet ? completedFrame.currentResult : completedFrame.resultBeforeIF // Optionally transform, then write back into the parent frame. const finalResult = performFunction ? performFunction(resolvedResult) : resolvedResult self._top.currentResult = finalResult // Return the proxy so the outer chain can continue. return self.createProxy() } return endif } // ── Forward to currentResult (UIRectangle or UIRectangle[]) ───────── const value = self._top.currentResult[prop] // Case A: method call if (typeof value === 'function') { return (...args: any[]) => { if (self._shouldExecute) { self._top.currentResult = value.apply(self._top.currentResult, args) } return self.createProxy() } } // Case B: property access (e.g. array .lastElement) if (self._shouldExecute) { self._top.currentResult = value } return self.createProxy() } }) as any } getProxy(): UIRectangleConditionalChain<any, any> { return this.createProxy() } }