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
text/typescript
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()
}
}