import * as fs from "node:fs";
// import * as path from "path";
import ts from "typescript";
import parseCss from "./parseCss";

/**
 * This is the transformer's configuration, the values are passed from the tsconfig.
 */
export interface TransformerConfig {
	cssFilePath: string;
}

enum RobloxEquivelantTags {
	body = "screengui",
	div = "frame",
	span = "textlabel",
	p = "textlabel",
	h1 = "textlabel",
	h2 = "textlabel",
	h3 = "textlabel",
	h4 = "textlabel",
	h5 = "textlabel",
	h6 = "textlabel",
	img = "imagelabel",
	button = "textbutton",
	input = "textbox",
	video = "videoframe",
}

let cachedCSS: Record<
	string,
	{ vars: Record<string, string> } | Record<string, string>
> = {};

const defaultCssFilePath = "src/roblox.css";

function watchCSSFile(filePath = defaultCssFilePath) {
	if (!fs.existsSync(filePath)) {
		console.warn(`CSS file not found: ${filePath}`);
		cachedCSS = {};
		return;
	}

	const loadCSS = () => {
		const cssContent = fs.readFileSync(filePath, "utf-8");

		cachedCSS = parseCss(cssContent);
	};

	loadCSS();

	fs.watch(filePath, (eventType) => {
		if (eventType === "change") {
			try {
				loadCSS();
				// console.log("CSS updated:", cssFilePath);
			} catch (error) {
				console.error("Failed to reload CSS:", error);
			}
		}
	});
}

/**
 * This is a utility object to pass around your dependencies.
 *
 * You can also use this object to store state, e.g prereqs.
 */
export class TransformContext {
	public factory: ts.NodeFactory;

	constructor(
		public program: ts.Program,
		public context: ts.TransformationContext,
		public config: TransformerConfig,
	) {
		this.factory = context.factory;
		watchCSSFile(this.config.cssFilePath);
	}

	/**
	 * Transforms the children of the specified node.
	 */
	transform<T extends ts.Node>(node: T): T {
		return ts.visitEachChild(
			node,
			(node) => visitNode(this, node),
			this.context,
		);
	}
}

function transformNameTagName(
	context: TransformContext,
	node: ts.JsxElement | ts.JsxSelfClosingElement,
): [ts.JsxElement | ts.JsxSelfClosingElement, boolean] {
	const tagName = ts.isJsxElement(node)
		? node.openingElement.tagName.getText()
		: node.tagName.getText();

	if (!(tagName in RobloxEquivelantTags))
		return [context.transform(node), false];

	const newTagName =
		RobloxEquivelantTags[tagName as keyof typeof RobloxEquivelantTags];
	const textTag = newTagName.includes("text");

	if (ts.isJsxElement(node)) {
		return [
			ts.factory.updateJsxElement(
				node,
				ts.factory.updateJsxOpeningElement(
					node.openingElement,
					ts.factory.createIdentifier(newTagName),
					node.openingElement.typeArguments,
					node.openingElement.attributes,
				),
				node.children,
				ts.factory.updateJsxClosingElement(
					node.closingElement,
					ts.factory.createIdentifier(newTagName),
				),
			),
			textTag,
		];
	}

	if (ts.isJsxSelfClosingElement(node)) {
		return [
			ts.factory.updateJsxSelfClosingElement(
				node,
				ts.factory.createIdentifier(newTagName),
				node.typeArguments,
				node.attributes,
			),
			textTag,
		];
	}

	return [context.transform(node), false];
}

function visitJsxElement(
	context: TransformContext,
	node: ts.JsxElement | ts.JsxSelfClosingElement,
) {
	if (!node.getSourceFile()) return context.transform(node);

	let transformedNode:
		| ts.JsxElement
		| ts.JsxSelfClosingElement
		| ts.JsxFragment = node;
	let textTag = false;

	transformedNode = removeAttribute(transformedNode, "defaultAnchorPoint");

	[transformedNode, textTag] = transformNameTagName(context, transformedNode);

	const className = pullClassNameFromAttributes(transformedNode);

	transformedNode = removeAttribute(transformedNode, "className");

	if (className) {
		const classes = className.split(" ");

		if (!cachedCSS) {
			console.warn("No cached CSS found, skipping class transformation");
		}

		transformedNode = injectAttributesFromStyles(
			/*context, */ transformedNode,
			classes,
			textTag,
		);
	}

	if (
		(ts.isJsxElement(transformedNode) || ts.isJsxFragment(transformedNode)) &&
		textTag
	) {
		let text = "`";
		const childrenFound: ts.JsxChild[] = [];

		transformedNode.children.forEach((child, index, arr) => {
			if (ts.isJsxText(child) && child.getText() !== "") {
				if (index > 0) {
					text += " ".repeat(child.getLeadingTriviaWidth()) + child.getText();
				} else text += child.getText();

				childrenFound.push(child);
			} else if (ts.isJsxExpression(child)) {
				text += `$\{${child.expression?.getText()}\}`;
				childrenFound.push(child);
			}
		});

		const lastIndex = text.lastIndexOf("\n");
		if (lastIndex !== -1)
			text = text.slice(0, lastIndex) + text.slice(lastIndex + 1);

		text += "`";
		if (text === "") return context.transform(transformedNode);

		const textAttribute = ts.factory.createJsxAttribute(
			ts.factory.createIdentifier("Text"),
			ts.factory.createJsxExpression(
				undefined,
				ts.factory.createIdentifier(text),
			),
		);

		if (ts.isJsxElement(transformedNode))
			transformedNode = ts.factory.updateJsxElement(
				transformedNode,
				ts.factory.updateJsxOpeningElement(
					transformedNode.openingElement,
					transformedNode.openingElement.tagName,
					transformedNode.openingElement.typeArguments,
					addOrReplaceAttribute(
						transformedNode.openingElement.attributes,
						textAttribute,
					),
				),
				transformedNode.children.filter(
					(child) => !childrenFound.includes(child),
				),
				transformedNode.closingElement,
			);
	}

	return context.transform(transformedNode);
}

function pullClassNameFromAttributes(
	node: ts.JsxElement | ts.JsxSelfClosingElement,
): string | undefined {
	const attributes = ts.isJsxSelfClosingElement(node)
		? node.attributes
		: node.openingElement.attributes;

	const classNameAttribute = attributes.properties.find(
		(attr) => ts.isJsxAttribute(attr) && attr.name.getText() === "className",
	) as ts.JsxAttribute | undefined;

	if (classNameAttribute?.initializer) {
		if (ts.isStringLiteral(classNameAttribute.initializer)) {
			return classNameAttribute.initializer.getText().slice(1, -1);
		}
	}

	return undefined;
}

function removeAttribute(
	node: ts.JsxElement | ts.JsxSelfClosingElement,
	attributeName: string,
): ts.JsxElement | ts.JsxSelfClosingElement {
	if (ts.isJsxSelfClosingElement(node)) {
		return ts.factory.updateJsxSelfClosingElement(
			node,
			node.tagName,
			node.typeArguments,
			ts.factory.createJsxAttributes(
				node.attributes.properties.filter(
					(attr) =>
						!(ts.isJsxAttribute(attr) && attr.name.getText() === attributeName),
				) as ts.JsxAttribute[],
			),
		);
	}

	return ts.factory.updateJsxElement(
		node,
		ts.factory.updateJsxOpeningElement(
			node.openingElement,
			node.openingElement.tagName,
			node.openingElement.typeArguments,
			ts.factory.createJsxAttributes(
				node.openingElement.attributes.properties
					.filter((attr) => attr.getSourceFile())
					.filter(
						(attr) =>
							!(
								ts.isJsxAttribute(attr) && attr.name.getText() === attributeName
							),
					) as ts.JsxAttribute[],
			),
		),
		node.children,
		node.closingElement,
	);
}

function addOrReplaceAttribute(
	attributes: ts.JsxAttributes,
	newAttribute: ts.JsxAttribute,
): ts.JsxAttributes {
	return ts.factory.createJsxAttributes([
		...attributes.properties,
		newAttribute,
	]);
}

const sizingConvertors = {
	rem: (value: number) => value * 16,
	em: (value: number, fontSize: number) => value * fontSize,
};

const defaultFontWeights = {
	[100]: "Enum.FontWeight.Thin",
	[200]: "Enum.FontWeight.ExtraLight",
	[300]: "Enum.FontWeight.Light",
	[400]: "Enum.FontWeight.Regular",
	[500]: "Enum.FontWeight.Medium",
	[600]: "Enum.FontWeight.SemiBold",
	[700]: "Enum.FontWeight.Bold",
	[800]: "Enum.FontWeight.ExtraBold",
	[900]: "Enum.FontWeight.Heavy",
};

function injectAttributesFromStyles(
	node: ts.JsxElement | ts.JsxSelfClosingElement,
	classes: string[],
	isTextNode: boolean,
): ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment {
	let transformedNode:
		| ts.JsxElement
		| ts.JsxSelfClosingElement
		| ts.JsxFragment = node;
	let attributes = ts.isJsxSelfClosingElement(transformedNode)
		? transformedNode.attributes
		: transformedNode.openingElement.attributes;

	const font = {
		family: "rbxasset://fonts/families/SourceSansPro.json",
		weight: "Enum.FontWeight.Regular",
		style: "Enum.FontStyle.Normal",
	};

	const padding = {
		left: [0, 0] as [number, number],
		right: [0, 0] as [number, number],
		top: [0, 0] as [number, number],
		bottom: [0, 0] as [number, number],
	};

	const flex = {
		enabled: false,
		direction: "Enum.FillDirection.Horizontal",
		horizontalFlex: "Enum.UIFlexAlignment.None",
		verticalFlex: "Enum.UIFlexAlignment.None",
		horizontalAlignment: "Enum.HorizontalAlignment.Left",
		verticalAlignment: "Enum.VerticalAlignment.Top",
		padding: "new UDim(0, 0)",
		wrap: false,
	};

	const anchorPoint = [0, 0] as [number, number];

	const border = {
		width: 0,
		color: "",
		transparency: 0,
	};

	const size = {
		minW: 0 as number | string,
		minH: 0 as number | string,
		maxW: "math.huge" as number | string,
		maxH: "math.huge" as number | string,
		width: "new UDim(0, 100)",
		height: "new UDim(0, 100)",
	};

	const position = {
		left: "new UDim(0, 0)",
		top: "new UDim(0, 0)",
	};

	classes.forEach((className) => {
		if (!(className in cachedCSS)) return;
		const style = cachedCSS[className];

		Object.entries(style).forEach(([key, value]: [string, string]) => {
			let attributeValue: ts.JsxExpression;
			switch (key) {
				case "color":
					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							value.includes("#")
								? `Color3.fromHex("${value}")`
								: `Color3.fromRGB(${value})`,
						),
					);
					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier(
								isTextNode ? "TextColor3" : "ImageColor3",
							),
							attributeValue,
						),
					);
					break;
				case "background-color":
					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							value.includes("#")
								? `Color3.fromHex("${value}")`
								: `Color3.fromRGB(${value})`,
						),
					);
					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("BackgroundColor3"),
							attributeValue,
						),
					);
					break;
				case "background-opacity":
					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createNumericLiteral(1 - Number.parseFloat(value)),
					);
					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("BackgroundTransparency"),
							attributeValue,
						),
					);
					break;
				case "text-opacity":
					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createNumericLiteral(1 - Number.parseFloat(value)),
					);
					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("TextTransparency"),
							attributeValue,
						),
					);
					break;
				case "opacity":
					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createNumericLiteral(1 - Number.parseFloat(value)),
					);
					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("Transparency"),
							attributeValue,
						),
					);
					break;
				case "border-radius": {
					const [radius, unit] = value.split(" ");
					const radiusValue = Number.parseFloat(radius);

					switch (unit) {
						case "rem":
							attributeValue = ts.factory.createJsxExpression(
								undefined,
								ts.factory.createIdentifier(
									`new UDim(0, ${sizingConvertors.rem(radiusValue)})`,
								),
							);
							break;
						case "em":
							attributeValue = ts.factory.createJsxExpression(
								undefined,
								ts.factory.createIdentifier(
									`new UDim(0, ${sizingConvertors.em(radiusValue, "font-size" in style ? Number.parseInt(style["font-size"]) : 16)})`,
								),
							);
							break;
						default:
							attributeValue = ts.factory.createJsxExpression(
								undefined,
								ts.factory.createIdentifier(`new UDim(0, ${radiusValue})`),
							);
							break;
					}

					const element = ts.factory.createJsxSelfClosingElement(
						ts.factory.createIdentifier("uicorner"),
						undefined,
						ts.factory.createJsxAttributes([
							ts.factory.createJsxAttribute(
								ts.factory.createIdentifier("CornerRadius"),
								attributeValue,
							),
						]),
					);

					if (ts.isJsxElement(transformedNode)) {
						transformedNode = ts.factory.updateJsxElement(
							transformedNode,
							transformedNode.openingElement,
							ts.factory.createNodeArray([
								...transformedNode.children,
								element,
							]),
							transformedNode.closingElement,
						);
					} else if (ts.isJsxSelfClosingElement(transformedNode)) {
						const openingElement = ts.factory.createJsxOpeningElement(
							transformedNode.tagName,
							transformedNode.typeArguments,
							transformedNode.attributes,
						);

						const closingElement = ts.factory.createJsxClosingElement(
							transformedNode.tagName,
						);

						// Wrap into a full JSX element
						transformedNode = ts.factory.createJsxElement(
							openingElement,
							[element],
							closingElement,
						);
					}
					break;
				}
				case "font-weight": {
					const fontWeight = Number.parseInt(value);
					if (fontWeight in defaultFontWeights) {
						font.weight =
							defaultFontWeights[fontWeight as keyof typeof defaultFontWeights];
					} else {
						//TODO: rich text
					}

					break;
				}
				case "font-style":
					font.style =
						value === "normal"
							? "Enum.FontStyle.Normal"
							: "Enum.FontStyle.Italic";
					break;
				case "font-size": {
					const [size, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							attributeValue = ts.factory.createJsxExpression(
								undefined,
								ts.factory.createIdentifier(
									`${sizingConvertors.rem(Number.parseFloat(size))}`,
								),
							);
							break;
						default:
							attributeValue = ts.factory.createJsxExpression(
								undefined,
								ts.factory.createIdentifier(size),
							);
							break;
					}

					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("TextSize"),
							attributeValue,
						),
					);
					break;
				}
				case "line-height": {
					const [height] = value.split(" ");

					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							`${Math.max(1, Math.min(3, Number.parseFloat(height)))}`,
						),
					);

					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("LineHeight"),
							attributeValue,
						),
					);
					break;
				}
				case "text-align": {
					let alignment: string;

					switch (value) {
						case "right":
							alignment = "Enum.TextXAlignment.Right";
							break;
						case "center":
							alignment = "Enum.TextXAlignment.Center";
							break;
						default:
							alignment = "Enum.TextXAlignment.Left";
							break;
					}

					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(alignment),
					);
					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("TextXAlignment"),
							attributeValue,
						),
					);
					break;
				}
				case "vertical-align": {
					let alignment: string;

					switch (value) {
						case "middle":
							alignment = "Enum.TextYAlignment.Middle";
							break;
						case "text-bottom":
						case "bottom":
							alignment = "Enum.TextYAlignment.Bottom";
							break;
						default:
							alignment = "Enum.TextYAlignment.Top";
							break;
					}

					attributeValue = ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(alignment),
					);
					attributes = addOrReplaceAttribute(
						attributes,
						ts.factory.createJsxAttribute(
							ts.factory.createIdentifier("TextYAlignment"),
							attributeValue,
						),
					);
					break;
				}
				case "padding-left":
				case "padding-right":
				case "padding-top":
				case "padding-bottom":
				case "padding": {
					const paddingType = key;
					const [unparsedPadding, unit] = value.split(" ");
					const parsedPadding = Number.parseFloat(unparsedPadding);

					const singlePadding = [0, 0] as [number, number];

					switch (unit) {
						case "rem":
							singlePadding[1] = sizingConvertors.rem(parsedPadding);
							break;
						case "em":
							singlePadding[1] = sizingConvertors.em(
								parsedPadding,
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							);
							break;
						default:
							singlePadding[1] = parsedPadding;
							break;
					}

					const paddingLeft =
						paddingType === "padding-left" || paddingType === "padding";
					const paddingRight =
						paddingType === "padding-right" || paddingType === "padding";
					const paddingTop =
						paddingType === "padding-top" || paddingType === "padding";
					const paddingBottom =
						paddingType === "padding-bottom" || paddingType === "padding";

					if (paddingLeft) padding.left = singlePadding;
					if (paddingRight) padding.right = singlePadding;
					if (paddingTop) padding.top = singlePadding;
					if (paddingBottom) padding.bottom = singlePadding;
					break;
				}
				case "aspect-ratio": {
					const parsedRatio = Number.parseFloat(
						new Function(`return (${value});`)(),
					);
					if (parsedRatio <= 0 || Number.isNaN(parsedRatio)) break;

					const element = ts.factory.createJsxSelfClosingElement(
						ts.factory.createIdentifier("uiaspectratioconstraint"),
						undefined,
						ts.factory.createJsxAttributes([
							ts.factory.createJsxAttribute(
								ts.factory.createIdentifier("AspectRatio"),
								ts.factory.createJsxExpression(
									undefined,
									ts.factory.createNumericLiteral(parsedRatio),
								),
							),
						]),
					);

					if (ts.isJsxElement(transformedNode)) {
						transformedNode = ts.factory.updateJsxElement(
							transformedNode,
							transformedNode.openingElement,
							ts.factory.createNodeArray([
								...transformedNode.children,
								element,
							]),
							transformedNode.closingElement,
						);
					} else if (ts.isJsxSelfClosingElement(transformedNode)) {
						const openingElement = ts.factory.createJsxOpeningElement(
							transformedNode.tagName,
							transformedNode.typeArguments,
							transformedNode.attributes,
						);

						const closingElement = ts.factory.createJsxClosingElement(
							transformedNode.tagName,
						);

						// Wrap into a full JSX element
						transformedNode = ts.factory.createJsxElement(
							openingElement,
							[element],
							closingElement,
						);
					}
					break;
				}
				case "display":
					if (value === "flex") flex.enabled = true;
					break;
				case "flex-direction":
					flex.direction = value.includes("column")
						? "Enum.FillDirection.Vertical"
						: "Enum.FillDirection.Horizontal";
					if (value.includes("reverse"))
						console.warn("Flex reverse not supported!");
					break;
				case "justify-content": {
					const direction =
						flex.direction === "Enum.FillDirection.Vertical"
							? "verticalFlex"
							: "horizontalFlex";

					switch (value) {
						case "flex-start": {
							const alignment =
								flex.direction === "Enum.FillDirection.Vertical"
									? "verticalAlignment"
									: "horizontalAlignment";
							flex[alignment] =
								alignment === "horizontalAlignment"
									? "Enum.HorizontalAlignment.Left"
									: "Enum.VerticalAlignment.Top";
							break;
						}
						case "flex-end": {
							const alignment =
								flex.direction === "Enum.FillDirection.Vertical"
									? "verticalAlignment"
									: "horizontalAlignment";
							flex[alignment] =
								alignment === "horizontalAlignment"
									? "Enum.HorizontalAlignment.Right"
									: "Enum.VerticalAlignment.Bottom";
							break;
						}
						case "center": {
							const alignment =
								flex.direction === "Enum.FillDirection.Vertical"
									? "verticalAlignment"
									: "horizontalAlignment";
							flex[alignment] =
								alignment === "horizontalAlignment"
									? "Enum.HorizontalAlignment.Center"
									: "Enum.VerticalAlignment.Center";
							break;
						}
						case "space-between":
							flex[direction] = "Enum.UIFlexAlignment.SpaceBetween";
							break;
						case "space-around":
							flex[direction] = "Enum.UIFlexAlignment.SpaceAround";
							break;
						case "space-evenly":
							flex[direction] = "Enum.UIFlexAlignment.SpaceEvenly";
							break;
						case "stretch":
							flex[direction] = "Enum.UIFlexAlignment.Fill";
							break;
						default:
							flex[direction] = "Enum.UIFlexAlignment.None";
							break;
					}
					break;
				}
				case "align-items": {
					const direction =
						flex.direction === "Enum.FillDirection.Vertical"
							? "horizontalFlex"
							: "verticalFlex";

					switch (value) {
						case "flex-start": {
							const alignment =
								flex.direction === "Enum.FillDirection.Vertical"
									? "horizontalAlignment"
									: "verticalAlignment";
							flex[alignment] =
								alignment === "horizontalAlignment"
									? "Enum.HorizontalAlignment.Left"
									: "Enum.VerticalAlignment.Top";
							break;
						}
						case "flex-end": {
							const alignment =
								flex.direction === "Enum.FillDirection.Vertical"
									? "verticalAlignment"
									: "horizontalAlignment";
							flex[alignment] =
								alignment === "horizontalAlignment"
									? "Enum.HorizontalAlignment.Right"
									: "Enum.VerticalAlignment.Bottom";
							break;
						}
						case "center": {
							const alignment =
								flex.direction === "Enum.FillDirection.Vertical"
									? "verticalAlignment"
									: "horizontalAlignment";
							flex[alignment] =
								alignment === "horizontalAlignment"
									? "Enum.HorizontalAlignment.Center"
									: "Enum.VerticalAlignment.Center";
							break;
						}
						case "stretch":
							flex[direction] = "Enum.UIFlexAlignment.Fill";
							break;
						// biome-ignore lint/suspicious/noFallthroughSwitchClause: Warn and then default
						case "baseline":
							console.log("Items baseline not supported!");
						default:
							flex[direction] = "Enum.UIFlexAlignment.None";
							break;
					}
					break;
				}
				case "gap": {
					const [padding, unit] = value.split(" ");
					const paddingValue = Number.parseFloat(padding);

					switch (unit) {
						case "rem":
							flex.padding = `new UDim(0, ${sizingConvertors.rem(paddingValue)})`;
							break;
						case "em":
							flex.padding = `new UDim(0, ${sizingConvertors.em(paddingValue, "font-size" in style ? Number.parseInt(style["font-size"]) : 16)})`;
							break;
						default:
							flex.padding = `new UDim(0, ${paddingValue})`;
							break;
					}

					break;
				}
				case "anchor-point-x":
					anchorPoint[0] = Number.parseFloat(value);
					break;
				case "anchor-point-y":
					anchorPoint[1] = Number.parseFloat(value);
					break;
				case "anchor-point": {
					const [x, y] = value.split(" ");

					anchorPoint[0] = Number.parseFloat(x);
					anchorPoint[1] = Number.parseFloat(y);
					break;
				}
				case "border-left-width":
				case "border-right-width":
				case "border-top-width":
				case "border-bottom-width":
					console.warn(
						"Individual border widths not supported! Use border-width instead.",
					);
					break;
				case "border-width": {
					const [width, unit] = value.split(" ");
					const widthValue = Number.parseFloat(width);

					switch (unit) {
						case "rem":
							border.width = sizingConvertors.rem(widthValue);
							break;
						case "em":
							border.width = sizingConvertors.em(
								widthValue,
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							);
							break;
						default:
							border.width = widthValue;
							break;
					}
					break;
				}
				case "border-color":
					border.color = value.includes("#")
						? `Color3.fromHex("${value}")`
						: `Color3.fromRGB(${value})`;
					break;
				case "border-opacity":
					border.transparency = 1 - Number.parseFloat(value);
					break;
				case "border-style":
					console.warn("Border style not supported!");
					break;
				case "min-width": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							size.minW = sizingConvertors.rem(Number.parseFloat(amount));
							break;
						case "em":
							size.minW = sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							);
							break;
						case "vw":
							size.minW = `workspace.CurrentCamera.ViewportSize.X * ${Number.parseFloat(amount) / 100}`;
							break;
						default:
							size.minW = Number.parseFloat(amount);
							break;
					}
					break;
				}
				case "min-height": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							size.minH = sizingConvertors.rem(Number.parseFloat(amount));
							break;
						case "em":
							size.minH = sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							);
							break;
						case "vh":
							size.minH = `workspace.CurrentCamera.ViewportSize.Y * ${Number.parseFloat(amount) / 100}`;
							break;
						default:
							size.minH = Number.parseFloat(amount);
							break;
					}
					break;
				}
				case "max-width": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							size.maxW = sizingConvertors.rem(Number.parseFloat(amount));
							break;
						case "em":
							size.maxW = sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							);
							break;
						case "vw":
							size.maxW = `workspace.CurrentCamera.ViewportSize.X * ${Number.parseFloat(amount) / 100}`;
							break;
						default:
							size.maxW = Number.parseFloat(amount);
							break;
					}
					break;
				}
				case "max-height": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							size.maxH = sizingConvertors.rem(Number.parseFloat(amount));
							break;
						case "em":
							size.maxH = sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							);
							break;
						case "vh":
							size.maxH = `workspace.CurrentCamera.ViewportSize.Y * ${Number.parseFloat(amount) / 100}`;
							break;
						default:
							size.maxH = Number.parseFloat(amount);
							break;
					}
					break;
				}
				case "width": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							size.width = `new UDim(0, ${sizingConvertors.rem(Number.parseFloat(amount))})`;
							break;
						case "em":
							size.width = `new UDim(0, ${sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							)})`;
							break;
						case "vw":
							size.width = `new UDim(${Number.parseFloat(amount) / 100}, 0)`;
							break;
						default:
							size.width = `new UDim(0, ${Number.parseFloat(amount)})`;
							break;
					}
					break;
				}
				case "height": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							size.height = `new UDim(0, ${sizingConvertors.rem(Number.parseFloat(amount))})`;
							break;
						case "em":
							size.height = `new UDim(0, ${sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							)})`;
							break;
						case "vh":
							size.height = `new UDim(${Number.parseFloat(amount) / 100}, 0)`;
							break;
						default:
							size.height = `new UDim(0, ${Number.parseFloat(amount)})`;
							break;
					}
					break;
				}
				case "left": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							position.left = `new UDim(0, ${sizingConvertors.rem(Number.parseFloat(amount))})`;
							break;
						case "em":
							position.left = `new UDim(0, ${sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							)})`;
							break;
						case "%":
						case "vw":
							position.left = `new UDim(${Number.parseFloat(amount) / 100}, 0)`;
							break;
						default:
							position.left = `new UDim(0, ${Number.parseFloat(amount)})`;
							break;
					}
					break;
				}
				case "top": {
					const [amount, unit] = value.split(" ");

					switch (unit) {
						case "rem":
							position.top = `new UDim(0, ${sizingConvertors.rem(Number.parseFloat(amount))})`;
							break;
						case "em":
							position.top = `new UDim(0, ${sizingConvertors.em(
								Number.parseFloat(amount),
								"font-size" in style ? Number.parseInt(style["font-size"]) : 16,
							)})`;
							break;
						case "%":
						case "vh":
							position.top = `new UDim(${Number.parseFloat(amount) / 100}, 0)`;
							break;
						default:
							position.top = `new UDim(0, ${Number.parseFloat(amount)})`;
							break;
					}
					break;
				}
				default:
					break;
			}
		});
	});

	if (
		isTextNode &&
		(font.family !== "rbxasset://fonts/families/SourceSansPro.json" ||
			font.weight !== "Enum.FontWeight.Regular" ||
			font.style !== "Enum.FontStyle.Normal")
	)
		attributes = addOrReplaceAttribute(
			attributes,
			ts.factory.createJsxAttribute(
				ts.factory.createIdentifier("FontFace"),
				ts.factory.createJsxExpression(
					undefined,
					ts.factory.createIdentifier(
						`new Font("${font.family}", ${font.weight}, ${font.style})`,
					),
				),
			),
		);

	const borderSizePixel = attributes.properties.find(
		(attr) =>
			//@ts-ignore
			ts.isJsxAttribute(attr) && attr.name.escapedText === "BorderSizePixel",
	) as ts.JsxAttribute | undefined;

	if (!borderSizePixel) {
		attributes = addOrReplaceAttribute(
			attributes,
			ts.factory.createJsxAttribute(
				ts.factory.createIdentifier("BorderSizePixel"),
				ts.factory.createJsxExpression(
					undefined,
					ts.factory.createNumericLiteral(0),
				),
			),
		);
	}

	if (
		padding.left[0] !== 0 ||
		padding.left[1] !== 0 ||
		padding.right[0] !== 0 ||
		padding.right[1] !== 0 ||
		padding.top[0] !== 0 ||
		padding.top[1] !== 0 ||
		padding.bottom[0] !== 0 ||
		padding.bottom[1] !== 0
	) {
		const element = ts.factory.createJsxSelfClosingElement(
			ts.factory.createIdentifier("uipadding"),
			undefined,
			ts.factory.createJsxAttributes([
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("PaddingLeft"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							`new UDim(${padding.left[0]}, ${padding.left[1]})`,
						),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("PaddingRight"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							`new UDim(${padding.right[0]}, ${padding.right[1]})`,
						),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("PaddingTop"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							`new UDim(${padding.top[0]}, ${padding.top[1]})`,
						),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("PaddingBottom"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							`new UDim(${padding.bottom[0]}, ${padding.bottom[1]})`,
						),
					),
				),
			]),
		);

		if (ts.isJsxElement(transformedNode)) {
			transformedNode = ts.factory.updateJsxElement(
				transformedNode,
				transformedNode.openingElement,
				ts.factory.createNodeArray([...transformedNode.children, element]),
				transformedNode.closingElement,
			);
		} else if (ts.isJsxSelfClosingElement(transformedNode)) {
			const openingElement = ts.factory.createJsxOpeningElement(
				transformedNode.tagName,
				transformedNode.typeArguments,
				transformedNode.attributes,
			);

			const closingElement = ts.factory.createJsxClosingElement(
				transformedNode.tagName,
			);

			// Wrap into a full JSX element
			transformedNode = ts.factory.createJsxElement(
				openingElement,
				[element],
				closingElement,
			);
		}
	}

	if (anchorPoint[0] !== 0 || anchorPoint[1] !== 0) {
		const anchorPointAttribute = ts.factory.createJsxAttribute(
			ts.factory.createIdentifier("AnchorPoint"),
			ts.factory.createJsxExpression(
				undefined,
				ts.factory.createIdentifier(
					`new Vector2(${anchorPoint[0]}, ${anchorPoint[1]})`,
				),
			),
		);

		attributes = addOrReplaceAttribute(attributes, anchorPointAttribute);
	}

	if (border.width !== 0 || border.color !== "" || border.transparency !== 0) {
		const borderElement = ts.factory.createJsxSelfClosingElement(
			ts.factory.createIdentifier("uistroke"),
			undefined,
			ts.factory.createJsxAttributes([
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("Thickness"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createNumericLiteral(border.width),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("Color"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(border.color),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("Transparency"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createNumericLiteral(border.transparency),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("ApplyStrokeMode"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier("Enum.ApplyStrokeMode.Border"),
					),
				),
			]),
		);

		if (ts.isJsxElement(transformedNode)) {
			transformedNode = ts.factory.updateJsxElement(
				transformedNode,
				transformedNode.openingElement,
				ts.factory.createNodeArray([
					...transformedNode.children,
					borderElement,
				]),
				transformedNode.closingElement,
			);
		} else if (ts.isJsxSelfClosingElement(transformedNode)) {
			const openingElement = ts.factory.createJsxOpeningElement(
				transformedNode.tagName,
				transformedNode.typeArguments,
				transformedNode.attributes,
			);

			const closingElement = ts.factory.createJsxClosingElement(
				transformedNode.tagName,
			);

			// Wrap into a full JSX element
			transformedNode = ts.factory.createJsxElement(
				openingElement,
				[borderElement],
				closingElement,
			);
		}
	}

	if (
		size.minW !== 0 ||
		size.minH !== 0 ||
		size.maxW !== "math.huge" ||
		size.maxH !== "math.huge"
	) {
		const sizeElement = ts.factory.createJsxSelfClosingElement(
			ts.factory.createIdentifier("uisizeconstraint"),
			undefined,
			ts.factory.createJsxAttributes([
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("MinSize"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							`new Vector2(${size.minW}, ${size.minH})`,
						),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("MaxSize"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(
							`new Vector2(${size.maxW}, ${size.maxH})`,
						),
					),
				),
			]),
		);

		if (ts.isJsxElement(transformedNode)) {
			transformedNode = ts.factory.updateJsxElement(
				transformedNode,
				transformedNode.openingElement,
				ts.factory.createNodeArray([...transformedNode.children, sizeElement]),
				transformedNode.closingElement,
			);
		} else if (ts.isJsxSelfClosingElement(transformedNode)) {
			const openingElement = ts.factory.createJsxOpeningElement(
				transformedNode.tagName,
				transformedNode.typeArguments,
				transformedNode.attributes,
			);

			const closingElement = ts.factory.createJsxClosingElement(
				transformedNode.tagName,
			);

			// Wrap into a full JSX element
			transformedNode = ts.factory.createJsxElement(
				openingElement,
				[sizeElement],
				closingElement,
			);
		}
	}

	if (size.width !== "new UDim(0, 100)" || size.height !== "new UDim(0, 100)") {
		attributes = addOrReplaceAttribute(
			attributes,
			ts.factory.createJsxAttribute(
				ts.factory.createIdentifier("Size"),
				ts.factory.createJsxExpression(
					undefined,
					ts.factory.createIdentifier(
						`new UDim2(${size.width}, ${size.height})`,
					),
				),
			),
		);
	}

	if (position.left !== "new UDim(0, 0)" || position.top !== "new UDim(0, 0)") {
		attributes = addOrReplaceAttribute(
			attributes,
			ts.factory.createJsxAttribute(
				ts.factory.createIdentifier("Position"),
				ts.factory.createJsxExpression(
					undefined,
					ts.factory.createIdentifier(
						`new UDim2(${position.left}, ${position.top})`,
					),
				),
			),
		);
	}

	if (flex.enabled) {
		const flexElement = ts.factory.createJsxSelfClosingElement(
			ts.factory.createIdentifier("uilistlayout"),
			undefined,
			ts.factory.createJsxAttributes([
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("FillDirection"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(flex.direction),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("HorizontalFlex"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(flex.horizontalFlex),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("VerticalFlex"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(flex.verticalFlex),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("HorizontalAlignment"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(flex.horizontalAlignment),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("VerticalAlignment"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(flex.verticalAlignment),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("Padding"),
					ts.factory.createJsxExpression(
						undefined,
						ts.factory.createIdentifier(flex.padding),
					),
				),
				ts.factory.createJsxAttribute(
					ts.factory.createIdentifier("Wraps"),
					ts.factory.createJsxExpression(
						undefined,
						flex.wrap ? ts.factory.createTrue() : ts.factory.createFalse(),
					),
				),
			]),
		);

		if (ts.isJsxElement(transformedNode)) {
			transformedNode = ts.factory.updateJsxElement(
				transformedNode,
				ts.factory.updateJsxOpeningElement(
					transformedNode.openingElement,
					transformedNode.openingElement.tagName,
					transformedNode.openingElement.typeArguments,
					attributes,
				),
				transformedNode.children,
				transformedNode.closingElement,
			);

			const parentNode = transformedNode.parent;

			if (parentNode && ts.isJsxElement(parentNode)) {
				const newChildren = ts.factory.createNodeArray([
					...parentNode.children,
					flexElement,
				]);

				transformedNode = ts.factory.updateJsxElement(
					parentNode,
					parentNode.openingElement,
					newChildren,
					parentNode.closingElement,
				);

				return transformedNode;
			}

			transformedNode = ts.factory.createJsxFragment(
				ts.factory.createJsxOpeningFragment(),
				[transformedNode, flexElement],
				ts.factory.createJsxJsxClosingFragment(),
			);

			return transformedNode;
		}
	}

	if (ts.isJsxSelfClosingElement(transformedNode)) {
		return ts.factory.updateJsxSelfClosingElement(
			transformedNode,
			transformedNode.tagName,
			transformedNode.typeArguments,
			attributes,
		);
	}

	return ts.factory.updateJsxElement(
		transformedNode,
		ts.factory.updateJsxOpeningElement(
			transformedNode.openingElement,
			transformedNode.openingElement.tagName,
			transformedNode.openingElement.typeArguments,
			attributes,
		),
		transformedNode.children,
		transformedNode.closingElement,
	);
}

function visitNode(
	context: TransformContext,
	node: ts.Node,
): ts.Node | ts.Node[] {
	if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node))
		return visitJsxElement(context, node);

	// We encountered a node that we don't handle above,
	// but we should keep iterating the AST in case we find something we want to transform.
	return context.transform(node);
}


import { createProducer } from "@rbxts/reflex";
import { DEFAULT_THEME } from "theme/constants";
import { Theme } from "../interfaces/theme";

export const themeProducer = createProducer(DEFAULT_THEME, {
	setTheme: (_state, theme: Theme) => theme
});

export type ThemeProducer = typeof themeProducer;


import React, {  StrictMode, useEffect } from "@rbxts/react";
import { ReflexProvider } from "@rbxts/react-reflex";
import { DEFAULT_THEME } from "theme/constants";
import { Theme } from "theme/interfaces";
import { CustomizedProps } from "theme/types";
import { themeProducer } from "./ThemeProducer";

export interface ThemeProviderProps {
	theme?: Theme;
}

function ThemeProvider(props: CustomizedProps<Instance, ThemeProviderProps>) {
	const { theme = DEFAULT_THEME, children } = props;

	// When theme prop changes
	useEffect(() => {
		themeProducer.setTheme(theme);
	}, [theme]);

	return (
		<StrictMode>
			<ReflexProvider producer={themeProducer}>{children}</ReflexProvider>
		</StrictMode>
	);
}

export default ThemeProvider;


import { useEffect, useMemo, useState } from "@rbxts/react";
import { themeProducer } from "../context/ThemeProducer";
import { Theme } from "theme/interfaces";

const useTheme = (): {
	theme: Theme;
	setTheme: (theme: Theme) => void;
} => {
	const [theme, setTheme] = useState<Theme>(themeProducer.getState());

	useEffect(() => {
		const unsubscribeFn = themeProducer.subscribe((state, _previousState) => {
			setTheme(state);
		});

		return () => {
			unsubscribeFn();
		};
	});

	const memoizedSetTheme = useMemo(() => themeProducer.setTheme, [themeProducer.setTheme]);

	return useMemo(() => ({ theme, setTheme: memoizedSetTheme }), [theme, setTheme]);
};
export default useTheme;


/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { PropsWithChildren } from "@rbxts/react";
import { WriteableStyle } from "theme/interfaces";

export interface CommonProps<T extends Instance> extends PropsWithChildren {
	className?: WriteableStyle<T>;
	component?: T;
	id?: React.Key;
	ref?: React.Ref<T>;
}

export type BaseDefaultProps<T extends Instance, P> = CommonProps<T> & P;

export type BaseCustomizedProps<T extends Instance, P> = BaseDefaultProps<T, P>;

export type DefaultProps<T extends Instance, P = {}> = BaseDefaultProps<T, P>;

export type CustomizedProps<T extends Instance, P = {}> = BaseCustomizedProps<T, P>;

export type CustomizedRef<T extends Instance> = T extends Instance
	? any
	: T extends keyof React.ReactElement<T>
	? WritableInstanceProperties<T>
	: never;

export type CustomizedForwardRefComponent<D extends Instance, P = {}> = <T extends Instance = D>(
	props: CustomizedProps<T, P> & WritableInstanceProperties<CustomizedRef<T>>,
) => React.Element | undefined;


import React, { useEffect, useRef, useState } from "@rbxts/react";
import { TweenService } from "@rbxts/services";
import { CustomizedProps, WriteableStyle } from "theme";
import { ButtonSize, ButtonColor, ButtonVariant } from "../types";
import useButtonStyles from "./Button.styles";
import { LoadingStroke } from "ui/packages/loadingStroke";

export type DefaultButtonComponent = TextButton;

export interface ButtonProps {
	text?: string;
	size?: ButtonSize;
	color?: ButtonColor;
	fullWidth?: boolean;
	variant?: ButtonVariant;
	// startIcon: Icons;
	// showStartIcon?: boolean;
	// endIcon: Icons;
	// showEndIcon?: boolean;
	disabled?: boolean;
	loading?: boolean;
	rounded?: boolean;
	hoveringDisabled?: boolean;
	animating?: boolean;
	onLeftClick?: () => void;
	onLeftDown?: () => void;
	onLeftUp?: () => void;
	onRightClick?: () => void;
	onRightDown?: () => void;
	onRightUp?: () => void;
	mouseEnter?: () => void;
	mouseLeave?: () => void;
}

function Button(props: CustomizedProps<DefaultButtonComponent, ButtonProps>) {
	const {
		text = "",
		variant = "contained",
		disabled = false,
		loading = false,
		rounded = false,
		hoveringDisabled = false,
		animating = false,
		onLeftClick,
		onLeftDown,
		onLeftUp,
		onRightClick,
		onRightDown,
		onRightUp,
		mouseEnter,
		mouseLeave,
		className,
		children,
		id,
		ref
	} = props;

	const { root, font, corner, stroke } = useButtonStyles(props);
	const [hovering, setHovering] = useState<boolean>(false);

	return (
		<textbutton
			key={id || "Button"}
			ref={ref}
			{...root}
			{...font}
			Active={!disabled}
			Text={text}
			BackgroundTransparency={
				(!hoveringDisabled && hovering) || disabled
					? 0.75
					: (className as WriteableStyle<DefaultButtonComponent>)?.BackgroundTransparency ??
					  (variant === "outlined" || variant === "text" ? 1 : 0)
			}
			Event={{
				MouseEnter: () => {
					setHovering(true);

					if (mouseEnter) {
						mouseEnter();
					}
				},
				MouseLeave: () => {
					setHovering(false);

					if (mouseLeave) {
						mouseLeave();
					}
				},
				MouseButton1Click: () => {
					if (!disabled && !loading) {
						if (onLeftClick) {
							onLeftClick();
						}
					}
				},
				MouseButton1Down: () => {
					if (!disabled && !loading) {
						if (onLeftDown) {
							onLeftDown();
						}
					}
				},
				MouseButton1Up: () => {
					if (!disabled && !loading) {
						if (onLeftUp) {
							onLeftUp();
						}
					}
				},
				MouseButton2Click: () => {
					if (!disabled && !loading) {
						if (onRightClick) {
							onRightClick();
						}
					}
				},
				MouseButton2Down: () => {
					if (!disabled && !loading) {
						if (onRightDown) {
							onRightDown();
						}
					}
				},
				MouseButton2Up: () => {
					if (!disabled && !loading) {
						if (onRightUp) {
							onRightUp();
						}
					}
				},
			}}
			{...className}
		>
			{variant === "outlined" && !animating && <uistroke {...stroke} />}
			{animating && <LoadingStroke animating={animating} />}
			{rounded && <uicorner {...corner} />}

			{children}
		</textbutton>
	);
}

export default Button;


import React from "@rbxts/react";
import { CustomizedProps } from "theme";
import { Orientations } from "ui/enums";
import useDividerStyles from "./Divider.styles";

type DefaultDividerComponent = Frame;

export interface DividerProps {
	position?: UDim2;
	orientation?: Orientations;
	padding?: number;
	color?: Color3;
	transparency?: number;
	weight?: number;
	anchorPoint?: Vector2;
}

function Divider<T extends DefaultDividerComponent>(props: CustomizedProps<T, DividerProps>) {
	const { className, id, ref } = props;

	const { root } = useDividerStyles(props);

	return <frame key={id || "Divider"} ref={ref as React.Ref<Frame>} {...root} BorderSizePixel={0} {...className} />;
}

export default Divider;


import React, { Component, ErrorInfo, ReactComponent } from "@rbxts/react";

export interface ErrorBoundaryProps {
	fallback?: (error: unknown) => React.Element;
}

export interface ErrorBoundaryState {
	hasError: boolean;
	message?: unknown;
}

@ReactComponent
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
	state: ErrorBoundaryState = {
		hasError: false,
	};

	componentDidCatch(message: unknown, info: ErrorInfo) {
		warn(message, info.componentStack);

		this.setState({
			hasError: true,
			message: `${message} ${info.componentStack}`,
		});
	}

	render() {
		if (this.state.hasError) {
			return this.props.fallback !== undefined ? this.props.fallback(this.state.message) : <></>;
		}
		return this.props.children;
	}
}

export default ErrorBoundary;


import React from "@rbxts/react";
import { Icons } from "ui/enums";
import { CustomizedProps, WriteableStyle } from "theme";
import useIconStyles from "./Icon.styles";

export interface IconProps {
	icon: Icons;
	size?: "xxs" | "xs" | "sm" | "md" | "lg" | "xl";
	tint?: Color3;
}

type DefaultIconComponent = ImageLabel;

function Icon(props: CustomizedProps<DefaultIconComponent, IconProps>) {
	const { icon, tint, className, id, ref } = props;
	const { container } = useIconStyles(props);

	return (
		<imagelabel
			key={id || "Icon"}
			ref={ref}
			{...container}
			{...className}
			Image={tostring(icon)}
			ImageColor3={tint ?? (container as WriteableStyle<ImageLabel>).ImageColor3}
		/>
	);
}

export default Icon;


import React, { useState } from "@rbxts/react";
import { Icons } from "ui/enums";
import { CustomizedProps, WriteableStyle } from "theme";
import useIconButtonStyles from "./IconButton.styles";

export interface IconButtonProps {
	icon: Icons;
	size?: "xxs" | "xs" | "sm" | "md" | "lg" | "xl";
	tint: Color3;
	selected?: boolean;
	onClick?: () => void;
}

type DefaultIconButtonComponent = ImageButton;

function IconButton(props: CustomizedProps<DefaultIconButtonComponent, IconButtonProps>) {
	const { icon, selected, tint, onClick, className, id, ref } = props;
	const { container } = useIconButtonStyles(props);

	const [hovering, setHovering] = useState(false);

	return (
		<imagebutton
			key={id || "IconButton"}
			ref={ref}
			{...container}
			{...className}
			BackgroundTransparency={hovering || selected ? 0.5 : 1}
			Image={tostring(icon)}
			ImageColor3={tint ?? (container as WriteableStyle<ImageLabel>).ImageColor3}
			Event={{
				MouseButton1Click: () => {
					if (onClick) {
						onClick();
					}
				},
				MouseEnter: () => {
					setHovering(true);
				},
				MouseLeave: () => {
					setHovering(false);
				},
			}}
		/>
	);
}

export default IconButton;


import React, { useState } from "@rbxts/react";
import { classNames, CustomizedProps, WriteableStyle } from "theme";
import { Divider } from "../../divider";
import { Orientations } from "ui/enums";
import { InputColor, InputMargin, InputVariant } from "../types";
import useInputStyles from "./Input.styles";

export type DefaultInputComponent = Frame;

export interface InputProps {
	color?: InputColor;
	disabled?: boolean;
	hasError?: boolean;
	helperText?: string;
	margin?: InputMargin;
	variant?: InputVariant;
	width?: UDim;
	text?: string;
	placeholder?: string;
	rounded?: boolean;
	clearsTextOnFocus?: boolean;
	onTextChanged?: (text: string) => void;
	onFocus?: () => void;
	onBlur?: () => void;
	onEnterPressed?: (text: string) => void;
}

function Input(props: CustomizedProps<DefaultInputComponent, InputProps>) {
	const {
		text,
		placeholder,
		helperText,
		variant = "standard",
		disabled = false,
		hasError = false,
		rounded = false,
		onTextChanged,
		onFocus,
		onBlur,
		onEnterPressed,
		className,
		id,
		ref
	} = props;

	const { root, font, margin, box, helper, errorColorFrame, errorColorText, divider, corner, stroke } =
		useInputStyles(props);
	const [input, setInput] = useState<string>(text ?? "");

	return (
		<frame key={id || "Input"} ref={ref} {...root} {...className}>
			<frame key="Margin" {...margin}>
				<textbox
					key={"Field"}
					{...font}
					{...box}
					Active={!disabled}
					Text={input}
					PlaceholderText={placeholder}
					// eslint-disable-next-line @typescript-eslint/no-explicit-any
					{...classNames(new Map<WriteableStyle<any>, boolean>([[errorColorText, hasError]]))}
					Event={{
						ReturnPressedFromOnScreenKeyboard: (rbx) => {
							if (onEnterPressed) {
								const text = rbx.Text;
								onEnterPressed(text);
							}
						},
						Focused: onFocus,
						FocusLost: (rbx) => {
							if (onTextChanged) {
								const text = rbx.Text;
								onTextChanged(text);
							}

							if (onBlur) {
								onBlur();
							}
						},
					}}
				>
					{variant === "outlined" && <uistroke {...stroke} />}
					{rounded && <uicorner {...corner} />}
				</textbox>
				<Divider
					padding={0}
					orientation={Orientations.Horizontal}
					className={classNames(
						// eslint-disable-next-line @typescript-eslint/no-explicit-any
						new Map<WriteableStyle<any>, boolean>([
							[errorColorFrame, hasError],
							[divider, true],
						]),
					)}
				/>
				{helperText !== undefined && (
					<textlabel
						key={"HelperText"}
						{...font}
						{...helper}
						// eslint-disable-next-line @typescript-eslint/no-explicit-any
						{...classNames(new Map<WriteableStyle<any>, boolean>([[errorColorText, hasError]]))}
						Text={helperText}
					/>
				)}
			</frame>
		</frame>
	);
}

export default Input;


import React, { useEffect, useRef } from "@rbxts/react";
import { CustomizedProps } from "theme";
import { TweenService } from "@rbxts/services";

export interface LoadingStrokeProps {
	animating?: boolean;
}

function LoadingStroke(props: CustomizedProps<UIStroke, LoadingStrokeProps>) {
	const {
		animating = false,
		className,
		children,
		id,
		ref
	} = props;

	const gradientRef = useRef<UIGradient>();

	useEffect(() => {
		if (gradientRef.current) {
			if (animating) {
				const tween = TweenService.Create(
					gradientRef.current,
					new TweenInfo(2, Enum.EasingStyle.Linear, Enum.EasingDirection.InOut, -1, false),
					{ Rotation: 360 },
				);
				tween.Play();
				return () => tween.Destroy();
			} else {
				gradientRef.current.Rotation = 0;
			}
		}
	}, [animating]);

	return (
		<uistroke
			key={id || "LoadingStroke"}
			ref={ref}
			Color={Color3.fromRGB(255, 255, 255)}
			Thickness={2}
			ApplyStrokeMode={Enum.ApplyStrokeMode.Border}
			{...className}>
				<uigradient
					ref={gradientRef}
					Transparency={
						new NumberSequence([
							new NumberSequenceKeypoint(0, 1),
							new NumberSequenceKeypoint(0.5, 0),
							new NumberSequenceKeypoint(1, 1),
						])
					}
					Rotation={0}
					Color={
						new ColorSequence([
							new ColorSequenceKeypoint(0, Color3.fromRGB(255, 255, 255)),
							new ColorSequenceKeypoint(0.5, Color3.fromRGB(235, 235, 235)),
							new ColorSequenceKeypoint(1, Color3.fromRGB(255, 255, 255)),
						])
					}
			/>
			{children}
		</uistroke>
	);
}

export default LoadingStroke;


import React, { useState, useMemo, ComponentType, useEffect } from "@rbxts/react";
import { useAsyncEffect, useDeferState } from "@rbxts/pretty-react-hooks";
import { ContentProvider } from "@rbxts/services";
import { ProgressBar } from "ui/packages/progressBar";
import { CustomizedProps } from "theme";
import usePreloaderStyles from "./Preloader.styles";

export interface PreloaderAssets {
	[key: string]: string | PreloaderAssets;
}

export interface CustomAdornmentProps {
	progress: number;
}

export interface PreloaderProps {
	preloaderAssets: Map<string, PreloaderAssets>;
	icon?: string;
	showPercentage?: boolean;
	loaderPrecision?: number;
	hideProgressBar?: boolean;
	showAssetName?: boolean;
	adornment?: ComponentType<CustomAdornmentProps>;
}

function Preloader(props: CustomizedProps<Frame, PreloaderProps>) {
	const {
		preloaderAssets,
		icon,
		showPercentage = false,
		loaderPrecision = 0,
		hideProgressBar = false,
		showAssetName = false,
		adornment: Adornment,
		className,
		children,
		id,
		ref
	} = props;
	const [percentage, setPercentage] = useState(0);
	const [loaded, setLoaded] = useState(false);

	const { container, logo, label, progressBar } = usePreloaderStyles(props);

	const [contentIds, contentNamesById] = useMemo(() => {
		const contentIds: string[] = [];
		const contentNamesById = new Map<string, string>();

		const scan = (assets: PreloaderAssets, prefix = "") => {
			for (const [name, asset] of pairs(assets)) {
				if (typeIs(asset, "string")) {
					contentIds.push(asset);
					contentNamesById.set(asset, `${prefix}${showAssetName ? `/${name}` : ""}`);
				} else {
					scan(asset, `${prefix}${showAssetName ? `/${name}` : ""}`);
				}
			}
		};

		preloaderAssets.forEach((assets, category) => {
			scan(assets, `${category}`);
		});

		return [contentIds, contentNamesById] as const;
	}, []);

	const [currentAsset, setCurrentAsset] = useDeferState<string>();

	useAsyncEffect(async () => {
		const unit = (1 / contentIds.size()) * 100;
		ContentProvider.PreloadAsync(contentIds, (assetId) => {
			setCurrentAsset(contentNamesById.get(assetId));

			setPercentage((prev) => {
				return math.clamp(prev + unit, 0, 100);
			});
		});

		setCurrentAsset(undefined);
	}, []);

	useEffect(() => {
		if (math.round(percentage) >= 100) {
			setLoaded(true);
		}
	}, [percentage]);

	const percentageStr = useMemo(() => {
		if (!showPercentage) {
			return undefined;
		}

		return `${string.format(`%.${loaderPrecision}f`, math.round(percentage))}%`;
	}, [showPercentage, loaderPrecision, percentage]);

	return (
		<>
			{loaded ? (
				{ children }
			) : (
				<frame key={id || "Preloader"} ref={ref} {...container} {...className}>
					{Adornment !== undefined && <Adornment progress={percentage} />}

					{icon !== undefined && <imagelabel {...logo} />}

					{!hideProgressBar && (
						<>
							<textlabel
								{...label}
								Text={`Loading ${currentAsset !== undefined ? currentAsset : "resources"}... ${percentageStr}`}
							/>
							<ProgressBar className={progressBar} progress={percentage} />
						</>
					)}
				</frame>
			)}
		</>
	);
}

export default Preloader;


import React from "@rbxts/react";
import { CustomizedProps, WriteableStyle } from "theme";
import useProgressBarStyles from "./ProgressBar.styles";

export interface ProgressBarProps {
	progress: number;
}

function ProgressBar(props: CustomizedProps<Frame, ProgressBarProps>) {
	const { progress, className, id, ref } = props;

	const { container, outer, stroke, inner, fill, corner } = useProgressBarStyles();

	return (
		<frame key={id || "ProgressBar"} ref={ref} {...container} {...className}>
			<frame key="Bar" {...outer}>
				<uicorner key="Corner" {...corner} />
				<uistroke {...stroke} />
				<frame {...inner}>
					<uicorner key="Corner" {...corner} />
					<frame {...fill} Size={new UDim2(progress / 100, 0, 1, 0)}>
						<uicorner key="Corner" {...corner} />
					</frame>
				</frame>
			</frame>
		</frame>
	);
}

export default ProgressBar;


import React, { useEffect, useState } from "@rbxts/react";
import { WriteableStyle } from "theme";
import useShadowStyles from "./Shadow.styles";

function Shadow() {
	const { container } = useShadowStyles();
	const [parent, setParent] = useState<GuiObject | undefined>(undefined);
	const [cornerRadius, setCornerRadius] = useState<UDim | undefined>();

	const defaultZIndex = (container as WriteableStyle<Frame>).ZIndex ?? 0;
	const [zIndex, setZIndex] = useState<number>(defaultZIndex);

	// Set any inherited properties
	useEffect(() => {
		if (!parent) return;

		const cornerRadius = parent?.FindFirstChildOfClass("UICorner")?.CornerRadius;
		setCornerRadius(cornerRadius);

		setZIndex((parent?.ZIndex ?? defaultZIndex) - 1);
		parent.ClipsDescendants = false; // Shadow won't work if this is true
	}, [parent]);

	return (
		<frame
			key="Shadow"
			{...container}
			ZIndex={zIndex}
			Event={{
				AncestryChanged: (rbx: Frame, parent: Instance) => {
					if (parent.IsA("GuiObject") && rbx.Parent === parent) {
						setParent(parent);
					}
				},
			}}
		>
			{cornerRadius !== undefined && <uicorner key="Corner" CornerRadius={cornerRadius} />}
		</frame>
	);
}

export default Shadow;


import React from "@rbxts/react";
import { CustomizedProps } from "theme";
import useSidebarStyles from "./Sidebar.styles";

export type DefaultSidebarComponent = Frame;

export interface SidebarProps {
	size: "compact" | "large";
	ignoreInset?: boolean;
}

function Sidebar<T extends DefaultSidebarComponent>(props: CustomizedProps<T, SidebarProps>) {
	const { className, children, id, ref } = props;
	const { root, container } = useSidebarStyles(props);

	return (
		<frame key={id || "Sidebar"} ref={ref as React.Ref<Frame>} {...root} {...className}>
			<frame key="Container" {...container}>
				{children}
			</frame>
		</frame>
	);
}

export default Sidebar;


import { BoatTween } from "@rbxts/boat-tween";
import React, { useEffect, useRef } from "@rbxts/react";
import { CustomizedProps, DEFAULT_THEME, WriteableStyle } from "theme";
import { Directions } from "ui/enums";
import { Shadow } from "ui/packages/shadow";
import ToastVariants from "../enums/ToastVariants";
import useToastStyles from "./Toast.styles";

export interface ToastProps {
	text: string;
	onDismiss: () => void;
	duration?: number;
	variant?: ToastVariants;
	toggledAt?: number;
}

const TWEEN_DURATION = 0.5;

function Toast(props: CustomizedProps<Frame, ToastProps>) {
	const { text, onDismiss, duration = 4, className, id, ref } = props;
	const { container, label, close, activePosition, inActivePosition } = useToastStyles(props);
	const frameRef = useRef<Frame>();

	const tween = (direction: Directions) => {
		const frame = frameRef.current;
		if (!frame) return;

		const tween = BoatTween.Create(frame, {
			Time: TWEEN_DURATION,
			DelayTime: 0,

			EasingStyle: "Linear",
			EasingDirection: "InOut",

			RepeatCount: 0,
			Reverses: false,

			StepType: "Stepped",

			Goal: {
				Position:
					direction === Directions.In
						? (activePosition as WriteableStyle<Frame>).Position
						: (inActivePosition as WriteableStyle<Frame>).Position,
			},
		});

		tween.Play();
		wait(TWEEN_DURATION);
	};

	useEffect(() => {
		tween(Directions.In);
		wait(duration);
		tween(Directions.Out);

		if (onDismiss) {
			onDismiss();
		}
	}, []);

	return (
		<frame key={id || "Toast"} ref={frameRef} {...container} {...className}>
			<uicorner  key="Corner"  CornerRadius={new UDim(0, DEFAULT_THEME.shape.borderRadius)} />
			<Shadow />

			<textlabel key="Label" {...label} Text={text} />
			<imagebutton
				key="Close"
				{...close}
				Event={{
					MouseButton1Click: () => {
						tween(Directions.Out);
						if (onDismiss) {
							onDismiss();
						}
					},
				}}
			/>
		</frame>
	);
}

export default Toast;


import React, { useCallback, useEffect, useState } from "@rbxts/react";
import { CustomizedProps, DEFAULT_THEME, WriteableStyle } from "theme";
import { Icon } from "ui/packages/icon";
import { Typography } from "ui/packages/typography";
import { Icons } from "../../../enums";
import Branch from "../interfaces/Branch";
import Leaf from "../interfaces/Leaf";
import Tree from "../interfaces/Tree";
import useTreeViewStyles from "./TreeView.styles";

type DefaultTreeViewComponent = Frame;

export interface TreeViewProps {
	tree: Tree;
	icon?: Icons;
	filter?: string;
}

function TreeView(props: CustomizedProps<DefaultTreeViewComponent, TreeViewProps>) {
	const { tree, icon, filter, className, id, ref } = props;
	const { root, header, list, gridLayout, row, branchIcon, branchTypography, leafIcon, leafTypography } =
		useTreeViewStyles();

	const [selectedBranch, setSelectedBranch] = useState<Branch | undefined>();
	const [selectedLeaf, setSelectedLeaf] = useState<Leaf | undefined>();
	const [expanded, setExpanded] = useState<Branch[]>([]);
	const [canvasSize, setCanvasSize] = useState<UDim2>(new UDim2(0, 0, 0, 0));

	const resizeScrollingFrame = (rbx: ScrollingFrame, child?: Instance) => {
		if (child && !child.IsA("GuiObject")) {
			return;
		}

		let height = 0;
		rbx.GetChildren().forEach((c) => {
			if (c.IsA("GuiObject")) {
				height += c.AbsoluteSize.Y;
			}
		});

		try {
			setCanvasSize(new UDim2(0, 0, 0, height));
		} catch {
			// Component is unmounting. Do nothing.
		}
	};

	const matchesFilter = useCallback(
		(title: string) => {
			let isMatch = false;

			if (filter === undefined || filter?.size() === 0) {
				return true;
			}

			const parts = title.split("/");

			parts.forEach((part) => {
				if (
					part.size() >= filter.size() &&
					string.sub(string.upper(part), 1, filter.size()) === string.upper(filter)
				) {
					isMatch = true;
				}
			});

			return isMatch;
		},
		[filter],
	);

	// Automatically expand branches that match filter
	useEffect(() => {
		if (filter === undefined || filter?.size() === 0) {
			setExpanded([]);
			return;
		}

		const newExpanded: Branch[] = [];

		// Figure out which branches should be expanded
		tree.branches.forEach((branch) => {
			const matchesBranchFilter = matchesFilter(branch.title);

			if (matchesBranchFilter) {
				// If branch is a match, whole branch should be expanded
				if (!newExpanded.includes(branch)) {
					newExpanded.push(branch);
				}
			} else {
				// If any of the leaves are a match, branch should be expanded
				branch.leaves.forEach((leaf) => {
					if (newExpanded.includes(branch)) return; // Already expanded.

					const matchesLeafFilter = matchesFilter(leaf.title);

					if (matchesLeafFilter) {
						newExpanded.push(branch);
					}
				});
			}
		});

		// Set expanded to new array
		setExpanded(newExpanded);
	}, [tree, filter]);

	return (
		<frame key={id || "TreeView"} ref={ref} {...root} {...className}>
			<Typography
				className={{ Text: tree.title, ...header } as WriteableStyle<TextLabel>}
				color={"textSecondary"}
				variant={"body"}
				family={"bold"}
			/>

			<scrollingframe
				key="List"
				{...list}
				CanvasSize={canvasSize}
				Event={{
					AncestryChanged: resizeScrollingFrame,
					ChildAdded: resizeScrollingFrame,
					ChildRemoved: resizeScrollingFrame,
				}}
			>
				<uigridlayout {...gridLayout} />

				{tree.branches.map((branch: Branch, branchIndex) => {
					const matchesBranchFilter = matchesFilter(branch.title);
					let hasMatchingLeaf = matchesBranchFilter;

					if (!hasMatchingLeaf) {
						for (const leaf of branch.leaves) {
							if (matchesFilter(leaf.title)) {
								hasMatchingLeaf = true;
								break;
							}
						}
					}

					if (matchesBranchFilter || hasMatchingLeaf) {
						return (
							<>
								<textbutton
									key={`${branch.title}-${branchIndex}`}
									{...row}
									Event={{
										MouseButton1Click: () => {
											if (branch.onClick) {
												branch.onClick();
											}

											// If there are leaves... Expand/Collapse
											if (branch.leaves.size() > 0) {
												const isExpanded = expanded.includes(branch);
												if (isExpanded) {
													setExpanded((oldExpanded) =>
														oldExpanded.filter((b) => b !== branch),
													);
												} else {
													setExpanded((oldExpanded) => [...oldExpanded, branch]);
												}
											} else {
												setSelectedBranch(branch);
											}
										},
									}}
								>
									{branch.leaves.size() > 0 && (
										<Icon
											icon={expanded.includes(branch) ? Icons.Expanded : Icons.Collapsed}
											size={"xxs"}
											className={branchIcon}
											tint={DEFAULT_THEME.options.constants.extendedPalette.Gray[70]}
										/>
									)}
									<Typography
										text={branch.title}
										className={{ ...branchTypography } as WriteableStyle<TextLabel>}
										color={"textPrimary"}
										variant={"body"}
										family={selectedBranch === branch ? "bold" : "default"}
									/>
								</textbutton>

								{expanded.includes(branch) &&
									branch.leaves.map((leaf: Leaf, leafIndex) => {
										const matchesLeafFilter = matchesFilter(leaf.title);
										if (matchesBranchFilter || matchesLeafFilter) {
											return (
												<textbutton
													key={`${branch.title}-${branchIndex}-${leaf.title}-${leafIndex}`}
													{...row}
													Event={{
														MouseButton1Click: () => {
															setSelectedLeaf(leaf);
															setSelectedBranch(branch);

															if (leaf.onClick) {
																leaf.onClick();
															}
														},
													}}
												>
													<Icon
														icon={icon || Icons.ListPrimary}
														size={"xs"}
														className={leafIcon}
														tint={DEFAULT_THEME.palette.secondary.main}
													/>
													<Typography
														text={leaf.title}
														className={{ ...leafTypography } as WriteableStyle<TextLabel>}
														color={"textPrimary"}
														variant={"body"}
														family={selectedLeaf === leaf ? "bold" : "default"}
													/>
												</textbutton>
											);
										} else {
											return <></>;
										}
									})}
							</>
						);
					} else {
						return <></>;
					}
				})}
			</scrollingframe>
		</frame>
	);
}

export default TreeView;


import React from "@rbxts/react";
import { CustomizedProps } from "theme";
import { FontFamilyVariant, FontSizeVariant } from "theme/interfaces/typography";
import { TypographyAlignment } from "../types/TypographyAlignment";
import { TypographyColor } from "../types/TypographyColor";
import { TypographyDisplay } from "../types/TypographyDisplay";
import useTypographyStyles from "./Typography.styles";

type DefaultTypographyComponent = TextLabel;

export interface TypographyProps {
	text?: string;
	variant?: FontSizeVariant;
	family?: FontFamilyVariant;
	color?: TypographyColor;
	display?: TypographyDisplay;
	align?: TypographyAlignment;
	noWrap?: boolean;
	lineClamp?: boolean;
}

function Typography<T extends DefaultTypographyComponent>(props: CustomizedProps<T, TypographyProps>) {
	const { text = "", className, children, id, ref } = props;

	const { root, variantToken } = useTypographyStyles(props);

	return (
		<textlabel key={id || "Typography"} ref={ref as React.Ref<TextLabel>} {...root} Text={text} {...className} {...variantToken}>
			{children}
		</textlabel>
	);
}

export default Typography;


local fs = require("@lune/fs")
local serde = require("@lune/serde")

if fs.isDir("bindings-package") then
	fs.removeDir("bindings-package")
end

fs.copy("src/Main/Bindings/Interface", "bindings-package")

local wallyToml = serde.decode("toml", fs.readFile("wally.toml"))
local package = wallyToml.package
package.name = package.name:split("/")[1] .. "/photobooth-bindings"
package.description = "Bindings for use with the photobooth plugin."
package.private = nil
package.exclude = { "**" }
package.include = { "src", "src/**", "wally.toml", "default.project.json" }
fs.writeFile("bindings-package/wally.toml", serde.encode("toml", { package = package }, false))

local defaultProject = serde.decode("json", fs.readFile("bindings-package/default.project.json"))
defaultProject.name = "photobooth-bindings"
defaultProject.tree.Wally["$path"] = "../wally.toml"
defaultProject.tree.LICENSE["$path"] = "../LICENSE"
fs.writeFile("bindings-package/default.project.json", serde.encode("json", defaultProject, true))


--!strict

local fs = require("@lune/fs")
local run = require("@root/lune/utils/run")

run("wally", { "install" })
for _, folderName in { "Packages", "DevPackages", "ServerPackages" } do
	if not fs.isDir(folderName) then
		fs.writeDir(folderName)
	end
end

run("rojo", { "sourcemap", "analysis.project.json", "-o", "sourcemap.json" })

for _, folderName in { "Packages", "DevPackages", "ServerPackages" } do
	if fs.isDir(folderName) then
		run("wally-package-types", { "--sourcemap", "sourcemap.json", folderName })
	end
end


--!strict

local RunService = game:GetService("RunService") :: RunService

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local StudioMode = require(pluginRoot.Utilities.StudioMode)
local Constants = require(pluginRoot.Constants)
local State = require(pluginRoot.Main.State)

local Plugin = {}

local DISPLAY_SCALE_WARNING = false

-- Private

local function waitForViewportSettled(minTime: number)
	local timestamp = os.clock()
	local viewportSize = workspace.CurrentCamera.ViewportSize

	while os.clock() - timestamp < minTime do
		local newViewportSize = workspace.CurrentCamera.ViewportSize
		if newViewportSize ~= viewportSize then
			viewportSize = newViewportSize
			timestamp = os.clock()
		end
		RunService.Heartbeat:Wait()
	end
end

local function updateScaleOS()
	local Screenshot = require(pluginRoot.Main.Photo.Captures.Screenshot)
	local scale = Screenshot.calculateScaleOS()

	State.mutate(function(state)
		state.temporary.scaleOS = scale
	end)

	if DISPLAY_SCALE_WARNING and not (scale.X == 1 and scale.Y == 1) then
		warn(table.concat({
			"",
			"Photobooth - OS scaling detected!",
			`The plugin will attempt to adjust for the following scale factor: ({scale.X}, {scale.Y})`,
			"For optimal results please use a resolution that matches your display and restart studio.",
		}, "\n"))
	end
end

-- Public

function Plugin.build(plugin: Plugin)
	if not (RunService:IsStudio() and RunService:IsEdit()) then
		return
	end

	State.setup(plugin)
	State.mutate(function(state)
		if Constants.AllowExperimentalMode then
			-- we set this one time at session start so that it remains constant for the entire session
			-- you can toggle this value in the settings, but then need to restart photobooth for changes to occur
			if StudioMode.isTeamCreate() then
				state.temporary.isExperimentalMode = false
			else
				state.temporary.isExperimentalMode = state.saved.experimentalModeEnabled
			end
		else
			state.temporary.isExperimentalMode = false
			state.saved.experimentalModeEnabled = false
		end
	end)

	-- load the modules after the state has been setup
	local UserInterface = require(pluginRoot.Main.UserInterface)
	local Bindings = require(pluginRoot.Main.Bindings)

	task.spawn(function()
		-- the viewport size must remain unchanged for X seconds
		waitForViewportSettled(1)
		updateScaleOS()
	end)

	local toolbar = plugin:CreateToolbar("Photobooth")

	UserInterface.mountCaptureViewport(plugin, toolbar)
	UserInterface.mountCaptureUI(plugin, toolbar)
	UserInterface.mountGallery(plugin, toolbar)

	Bindings.setup(plugin)
end

return Plugin


--!strict

-- lune needs to be able to read this file
-- so do not use roblox data types

return {
	AllowFullscreen = true,
	AllowExperimentalMode = true,

	TileHighResImagesEnabled = true,
	TileHighResImagesSize = { X = 1024, Y = 1024 },
	MaxCaptureRectSize = { X = 2048, Y = 2048 },

	DockWidgetSize = { X = 500, Y = 350 },
	ConfigWindowSize = { X = 260, Y = 320 },
}


--!strict

local Binding = {}
local bindable = assert(script:FindFirstChild("Bindable") :: BindableFunction?)

-- Private

local function isConnected()
	local pcallSuccess, invokeSuccess, result
	local thread = task.spawn(function()
		pcallSuccess = pcall(function()
			invokeSuccess, result = bindable:Invoke("isConnected")
		end)
	end)

	local value = false
	if coroutine.status(thread) == "dead" then
		local closeSuccess, _errorMessage = coroutine.close(thread)
		if closeSuccess and pcallSuccess and invokeSuccess then
			value = (result == true)
		end
	end

	return value
end

local function tryConnecting()
	if not isConnected() then
		local camera = game:GetService("CaptureService"):FindFirstChild("Photobooth") :: Camera?
		local connectBinding = camera and camera:FindFirstChild("ConnectBinding") :: BindableFunction?

		if connectBinding then
			connectBinding:Invoke(script.Parent)
		end
	end
end

-- Public

function Binding.isConnected()
	tryConnecting()
	return isConnected()
end

function Binding.invoke<T..., U...>(identifier: string, ...: T...): U...
	tryConnecting()

	local results = table.pack(bindable:Invoke(identifier, ...))
	local success = table.unpack(results, 1, 1)

	if not success then
		error(table.unpack(results, 2, 2), 2)
	end

	return table.unpack(results, 2)
end

return Binding


--!strict

local ScriptEditorService = game:GetService("ScriptEditorService") :: ScriptEditorService

local CachelessRequire = {}

-- Private

local function getExecutionCallback(script: LuaSourceContainer, params: { string })
	local tmpScript = Instance.new("ModuleScript")
	tmpScript.Name = script.Name
	tmpScript.Archivable = false

	local mergedParams = { "script", "require" }
	for _, param in params do
		table.insert(mergedParams, param)
	end

	local headers = {}
	local lines = ScriptEditorService:GetEditorSource(script):split("\n")
	for i = 1, #lines do
		local line = lines[i]
		if line:sub(1, 3) == "--!" then
			table.insert(headers, line .. "\n")
		end
	end

	local source = table.concat(lines, "\n", #headers + 1)
	ScriptEditorService:UpdateSourceAsync(tmpScript, function()
		-- The formatting is crazy here, but we retain line numbers
		return `{table.concat(headers)}return (function({table.concat(mergedParams, ", ")}) {source} end)`
	end)

	local callback = require(tmpScript) :: (...any) -> ...any
	tmpScript:Destroy()
	return callback
end

-- Public

function CachelessRequire.execute(root: LuaSourceContainer, env: { [string]: any })
	assert(typeof(root) == "Instance" and root:IsA("LuaSourceContainer"), "Not a LuaSourceContainer.")

	local nEnv = 0
	local params, args = {}, {}
	for key, value in env do
		nEnv = nEnv + 1
		table.insert(params, key)
		table.insert(args, value)
	end

	local import
	local required = {}
	function import(module: ModuleScript)
		if not required[module] then
			local callback = getExecutionCallback(module, params)
			required[module] = table.pack(callback(module, import, table.unpack(args :: { any }, 1, nEnv)))
		end
		local packed = required[module]
		return table.unpack(packed :: { any }, 1, packed.n)
	end

	return import(root :: ModuleScript)
end

--

return CachelessRequire


--!strict

-- this module handles calculating rects as they would be rendered on a screen.
-- since there's no such thing as half a pixel, what's actually rendered
-- can change by a pixel or two.

local RenderRect = {}

function RenderRect.fromAbs(position: Vector2, size: Vector2)
	local minX = math.round(position.X)
	local minY = math.round(position.Y)

	local maxX = math.round(position.X + size.X)
	local maxY = math.round(position.Y + size.Y)

	return Rect.new(minX, minY, maxX, maxY)
end

function RenderRect.fromRect(rect: Rect)
	return RenderRect.fromAbs(rect.Min, rect.Max - rect.Min)
end

function RenderRect.fromViewportCenter(size: Vector2)
	local viewportSize = (workspace.CurrentCamera :: Camera).ViewportSize
	local absPosition = (viewportSize - size) / 2
	return RenderRect.fromAbs(absPosition, size)
end

function RenderRect.fromGuiObject(subject: GuiObject)
	local absPosition = subject.AbsolutePosition
	local absSize = subject.AbsoluteSize
	return RenderRect.fromAbs(absPosition, absSize)
end

return RenderRect


--!strict

local SelectionService = game:GetService("Selection") :: Selection
local ChangeHistoryService = game:GetService("ChangeHistoryService") :: ChangeHistoryService

local Reversible = {}

-- Private

local pcall: <T...>((T...) -> (), T...) -> (boolean, string?) = pcall :: any

local function simpleFuture<T>()
	local result: T
	local completed = false
	local awaiting: { [thread]: boolean } = {}

	local function isCompleted()
		return completed
	end

	local function resolve(value: T)
		assert(not completed, "Already completed")

		completed = true
		result = value

		local threads = awaiting
		awaiting = {}
		for thread, _ in threads do
			coroutine.resume(thread)
		end
	end

	local function expect()
		if not completed then
			local thread = coroutine.running()
			awaiting[thread] = true
			coroutine.yield()
		end
		return result
	end

	return {
		isCompleted = isCompleted,
		resolve = resolve,
		expect = expect,
	}
end

local function createRevertCameraCallback()
	local camera = workspace.CurrentCamera :: Camera
	local properties = {
		CameraType = camera.CameraType,
		CameraSubject = camera.CameraSubject,
		FieldOfView = camera.FieldOfView,
		CFrame = camera.CFrame,
		Focus = camera.Focus,
	}

	return function()
		local currentCamera = workspace.CurrentCamera :: Camera
		currentCamera.CameraType = properties.CameraType
		currentCamera.CameraSubject = properties.CameraSubject
		currentCamera.FieldOfView = properties.FieldOfView
		currentCamera.CFrame = properties.CFrame
		currentCamera.Focus = properties.Focus
	end
end

-- Public

function Reversible.wrap(callback: () -> ())
	local selection = SelectionService:Get()
	local revertCamera = createRevertCameraCallback()

	local identifier
	if ChangeHistoryService:IsRecordingInProgress() then
		-- stylua: ignore
		warn("Unable to use Reversible.wrap since ChangeHistoryService is already recording. This may be due to the multi-line command bar.")
	else
		identifier = assert(ChangeHistoryService:TryBeginRecording("photobooth_bindings", "Photobooth"))
	end

	local callbackFuture = simpleFuture()
	local callbackThread = task.spawn(function()
		local success, err = pcall(function()
			callback()
		end)

		if not success then
			if identifier then
				ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Cancel)
			end

			SelectionService:Set(selection)
			revertCamera()
		elseif identifier then
			ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
		end

		callbackFuture.resolve({
			success = success,
			err = err,
		})
	end)

	local alreadyCalled = false
	local function cancel()
		if not alreadyCalled then
			alreadyCalled = true

			if not callbackFuture.isCompleted() then
				while coroutine.status(callbackThread) ~= "suspended" do
					task.wait()
				end

				coroutine.close(callbackThread)

				if not callbackFuture.isCompleted() then
					if identifier then
						ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Cancel)
					end

					SelectionService:Set(selection)
					revertCamera()

					callbackFuture.resolve({
						success = false,
						err = "Cancelled",
					})
				end
			end
		end
	end

	local function expect()
		local result = callbackFuture.expect() :: any
		return result.success :: boolean, result.err :: string?
	end

	return expect :: () -> (boolean, string?), cancel :: () -> ()
end

--

return Reversible


--!strict

--[[
	Instructions:
		1. Copy this template to the StarterGui.
		2. Place all the UI elements you'd like to capture in the CaptureFolder.
		3. Run `require(game.ServerStorage.Photobooth.Bindings).execute(game.StarterGui.BatchUI.Action)` in the command bar.
		4. After completion your captures will be in a timestamped folder under the StarterGui.

	CAPTURE_DELAY
		> This represents how much time to delay once the capture target is about to be captured. This can be useful
		> when your targets have elements that may need a moment to fully render (i.e. viewport frames)
--]]

local CAPTURE_DELAY = 0

local StarterGui = game:GetService("StarterGui")
local ServerStorage = game:GetService("ServerStorage")

local Photobooth = require(ServerStorage.Photobooth.Bindings)

local screen = script.Parent
local folder = screen.CaptureFolder

local function capture()
	local outputFolder = Instance.new("Folder")
	for _, child in folder:GetChildren() do
		if child:IsA("GuiObject") and child.Visible then
			local result = assert(
				Photobooth.captureUI({
					subject = child,
					type = "NoBackground",
					delay = CAPTURE_DELAY,
				}),
				"Capture failed!"
			)

			result.Name = child.Name
			result.Parent = outputFolder
		end
	end
	return outputFolder
end

local expect = Photobooth.reversible(function()
	local results = capture()
	results.Name = DateTime.now():ToIsoDate()
	results.Archivable = false
	results.Parent = StarterGui
end)

local success, err = expect()
if not success then
	warn(err)
end


--!strict

--[[
	Instructions:
		1. Copy this template to the StarterGui.
		2. Create a folder in the workspace named "FitToFrameTargets" and put all the models you want to capture in it.
		3. Hide everything that is not in the target folder so that it is not visible in the workspace.
		4. Resize the "CaptureFrame" to your desired dimensions.
		5. Make any rotational adjustments to the targets as the camera will have identity rotation when taking the captures.
		6. Run `require(game.ServerStorage.Photobooth.Bindings).execute(game.StarterGui.FitToFrame.Action)` in the command bar.
		7. After completion your captures will be in a timestamped folder under the StarterGui.

	UNIFORM_SCALE
		> If you have some capture targets that are larger than others you may want to use a consistent camera distance so
		> that all targets are captured showing their relative size to each other.

	CAPTURE_DELAY
		> This represents how much time to delay once the capture target is about to be captured. This can be useful
		> when your targets have particles that need time to emit before capturing.
--]]

local UNIFORM_SCALE = false
local CAPTURE_DELAY = 0

local StarterGui = game:GetService("StarterGui")
local ServerStorage = game:GetService("ServerStorage")

local Photobooth = require(ServerStorage.Photobooth.Bindings)

local frame = script.Parent.CaptureFrame
local folder = workspace:FindFirstChild("FitToFrameTargets") :: Folder

-- Helpers

local function validate()
	-- we turn streaming turned off so that we can move models in/out of the workspace very quickly.
	assert(not workspace.StreamingEnabled, "Cannot capture with streaming enabled turned on")
	assert(folder, "No target folder found!")

	assert(
		frame.AnchorPoint == Vector2.new(0.5, 0.5) and frame.Position == UDim2.fromScale(0.5, 0.5),
		"CaptureFrame must be in the center of the screen."
	)
end

-- Capture

local function capture()
	local camera = workspace.CurrentCamera :: Camera
	local viewportSize = camera.ViewportSize

	local prevCameraCFrame = camera.CFrame
	local prevCameraType = camera.CameraType
	camera.CameraType = Enum.CameraType.Scriptable
	folder.Parent = nil

	-- figure out the rect we're capturing and some math values we'll need to fit inside it
	local rect = Photobooth.rect.fromGuiObject(frame)
	local aspect = rect.Width / rect.Height
	local scaleY = rect.Height / viewportSize.Y
	local scaledFov2 = math.atan(math.tan(math.rad(camera.FieldOfView / 2)) * scaleY)
	local sinFov2 = math.sin(scaledFov2 * math.min(1, aspect))

	local maxRadius = 0
	local radiusByChild = {}
	for _, child in folder:GetChildren() do
		local model = Instance.new("Model")
		child.Parent = model

		-- measure the radius as hypotenuse / 2 of the bounding box facing the camera
		local _, boundSize = model:GetBoundingBox()
		local radius = boundSize.Magnitude / 2

		maxRadius = math.max(maxRadius, radius)
		radiusByChild[child] = radius

		child.Parent = folder
		model:Destroy()
	end

	local outputFolder = Instance.new("Folder")
	for _, child in folder:GetChildren() do
		local model = Instance.new("Model")
		child.Parent = model
		model.Parent = workspace

		local centerCF = model:GetPivot()
		local rotationCF = centerCF.Rotation
		model:PivotTo(rotationCF)

		-- adjust the camera such that model is contained in the rect
		local radius = if UNIFORM_SCALE then maxRadius else radiusByChild[child]
		camera.CFrame = CFrame.fromEulerAnglesYXZ(0, math.pi, 0) * CFrame.new(0, 0, radius / sinFov2)

		-- let the camera render since we just changed our scene
		Photobooth.yieldUntilNextFrame()

		-- use photobooth to capture
		local result = assert(
			Photobooth.captureViewport({
				rect = rect,
				type = "NoSkybox",
				delay = CAPTURE_DELAY,
			}),
			"Capture failed!"
		)

		result.Name = child.Name
		result.Parent = outputFolder

		model:PivotTo(centerCF)
		child.Parent = folder
		model:Destroy()
	end

	camera.CFrame = prevCameraCFrame
	camera.CameraType = prevCameraType
	folder.Parent = workspace

	return outputFolder
end

validate()

local expect = Photobooth.reversible(function()
	local results = capture()
	results.Name = DateTime.now():ToIsoDate()
	results.Archivable = false
	results.Parent = StarterGui
end)

local success, err = expect()
if not success then
	warn(err)
end


--!strict

--[[
	Instructions:
		1. Copy this template to the StarterGui.
		2. Create a folder in the workspace named "StaticFrameTargets" and put all the models you want to capture in it.
		3. Hide everything that is not in the target folder so that it is not visible in the workspace.
		4. Resize the "CaptureFrame" to your desired dimensions.
		5. Adjust your camera and move any target models so that they're in the bounds of the capture frame.
		5. Run `require(game.ServerStorage.Photobooth.Bindings).execute(game.StarterGui.StaticFrame.Action)` in the command bar.
		6. After completion your captures will be in a timestamped folder under the StarterGui.

	CAPTURE_DELAY
		> This represents how much time to delay once the capture target is about to be captured. This can be useful
		> when your targets have particles that need time to emit before capturing.
--]]

local CAPTURE_DELAY = 0

local StarterGui = game:GetService("StarterGui")
local ServerStorage = game:GetService("ServerStorage")

local Photobooth = require(ServerStorage.Photobooth.Bindings)

local frame = script.Parent.CaptureFrame
local folder = workspace:FindFirstChild("StaticFrameTargets") :: Folder

-- Capture

local function validate()
	assert(not workspace.StreamingEnabled, "Cannot capture with streaming enabled turned on")
	assert(folder, "No target folder found!")

	assert(
		frame.AnchorPoint == Vector2.new(0.5, 0.5) and frame.Position == UDim2.fromScale(0.5, 0.5),
		"CaptureFrame must be in the center of the screen."
	)
end

local function capture()
	local camera = workspace.CurrentCamera :: Camera

	local prevCameraCFrame = camera.CFrame
	local prevCameraType = camera.CameraType
	camera.CameraType = Enum.CameraType.Scriptable
	folder.Parent = nil

	local rect = Photobooth.rect.fromGuiObject(frame)
	local outputFolder = Instance.new("Folder")
	for _, child in folder:GetChildren() do
		child.Parent = workspace

		-- let the camera render since we just changed our scene
		Photobooth.yieldUntilNextFrame()

		-- use photobooth to capture
		local result = assert(
			Photobooth.captureViewport({
				rect = rect,
				type = "NoSkybox",
				delay = CAPTURE_DELAY,
			}),
			"Capture failed!"
		)

		result.Name = child.Name
		result.Parent = outputFolder

		child.Parent = folder
	end

	camera.CFrame = prevCameraCFrame
	camera.CameraType = prevCameraType
	folder.Parent = workspace

	return outputFolder
end

validate()

local expect = Photobooth.reversible(function()
	local results = capture()
	results.Name = DateTime.now():ToIsoDate()
	results.Archivable = false
	results.Parent = StarterGui
end)

local success, err = expect()
if not success then
	warn(err)
end


--!strict

export type UICaptureTypes = "NoBackground" | "AlphaMask"
export type ViewportCaptureTypes = "Unmodified" | "NoSkybox"

export type CaptureViewportOptions = {
	rect: Rect,
	type: ViewportCaptureTypes,

	delay: number?,
	scale: Vector2?,
	alphaBleed: boolean?,
}

export type CaptureUIOptions = {
	subject: GuiObject,
	type: UICaptureTypes,

	delay: number?,
	scale: Vector2?,
	alphaBleed: boolean?,
}

return {}


--!strict

local Types = require(script.Types)
local Wally = require(script.Wally)
local Binding = require(script.Binding)

local RenderRect = require(script.Helpers.RenderRect)
local Reversible = require(script.Helpers.Reversible)
local CachelessRequire = require(script.Helpers.CachelessRequire)

local Bindings = {}

export type UICaptureTypes = Types.UICaptureTypes
export type ViewportCaptureTypes = Types.ViewportCaptureTypes

export type CaptureViewportOptions = Types.CaptureViewportOptions
export type CaptureUIOptions = Types.CaptureUIOptions

-- Public

Bindings.rect = RenderRect
Bindings.reversible = Reversible.wrap

function Bindings.execute(script: LuaSourceContainer)
	assert(game:GetService("CoreGui"), "This should only be run in the command line.")
	return CachelessRequire.execute(script, {})
end

function Bindings.getVersion(): string
	return Binding.invoke("getVersion")
end

function Bindings.getPackageVersion(): string
	return assert(script:GetAttribute("PackageVersion") :: string?, "No package version attribute found.")
end

function Bindings.getOSScale(): Vector2
	return Binding.invoke("getOSScale")
end

function Bindings.yieldUntilNextFrame(): ()
	return Binding.invoke("yieldUntilNextFrame")
end

function Bindings.captureViewport(options: CaptureViewportOptions): MeshPart?
	return Binding.invoke("captureViewport", options)
end

function Bindings.captureUI(options: CaptureUIOptions): MeshPart?
	return Binding.invoke("captureUI", options)
end

function Bindings.isConnected(): boolean
	return Binding.isConnected()
end

function Bindings.isCompatible(): boolean
	local pkgVersion = Wally.package.version
	return Binding.invoke("compatibilityCheck", pkgVersion)
end

return Bindings


--!strict

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local gt = require(pluginRoot.Packages.GreenTea)
local Sift = require(pluginRoot.Packages.Sift)

local Captures = require(pluginRoot.Main.Photo.Captures)
local Photo = require(pluginRoot.Main.Photo)

local captureTypeLiterals = Sift.Array.map(Captures.UserInterface.Order, function(value)
	return gt.literal(value)
end)

local gtOptions = gt.table({
	type = gt.union(unpack(captureTypeLiterals)),
	subject = gt.IsA.GuiObject(),
	delay = gt.opt(gt.number()),
	scale = gt.opt(gt.Vector2()),
	alphaBleed = gt.opt(gt.bool()),
})

-- stylua: ignore
local typechecker = gt.fn(
	gt.args(gtOptions),
	gt.returns(gt.opt(gt.IsA("MeshPart")))
)

local function callback(options: typeof(gtOptions))
	local result = Photo.captureUI(options)
	local exported = Photo.export(result, nil)
	return exported[1]
end

return {
	identifier = "captureUI",
	callback = gt.wrapFn(typechecker, callback),
}


--!strict

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local gt = require(pluginRoot.Packages.GreenTea)
local Sift = require(pluginRoot.Packages.Sift)

local RenderRect = require(pluginRoot.Main.Bindings.Interface.Helpers.RenderRect)
local Captures = require(pluginRoot.Main.Photo.Captures)
local Camera = require(pluginRoot.Utilities.Camera)
local State = require(pluginRoot.Main.State)
local Photo = require(pluginRoot.Main.Photo)

local captureTypeLiterals = Sift.Array.map(Captures.Viewport.Order, function(value)
	return gt.literal(value)
end)

local gtOptions = gt.table({
	type = gt.union(unpack(captureTypeLiterals)),
	rect = gt.Rect(),
	delay = gt.opt(gt.number()),
	scale = gt.opt(gt.Vector2()),
	alphaBleed = gt.opt(gt.bool()),
})

-- stylua: ignore
local typechecker = gt.fn(
	gt.args(gtOptions),
	gt.returns(gt.opt(gt.IsA("MeshPart")))
)

local function callback(options: typeof(gtOptions))
	local scaleOS = State.read().temporary.scaleOS
	local captureScale = options.scale or Vector2.new(1, 1)

	local camera = workspace.CurrentCamera
	local viewportSize = camera.ViewportSize

	local prevCameraCF = camera.CFrame
	local prevCameraType = camera.CameraType
	camera.CameraType = Enum.CameraType.Scriptable

	local scaledRect: Rect
	if options.rect.Min == Vector2.zero and options.rect.Max == viewportSize then
		local scaledViewportX = math.round(viewportSize.X * scaleOS.X)
		local scaledViewportY = math.round(viewportSize.Y * scaleOS.Y)
		scaledRect = Rect.new(Vector2.new(0, 0), Vector2.new(scaledViewportX, scaledViewportY))
	else
		local viewportDelta = (viewportSize * scaleOS) - viewportSize
		local absPosition = options.rect.Min + viewportDelta / 2
		local absSize = options.rect.Max - options.rect.Min

		local center = absPosition + absSize / 2
		local offset = (absSize / 2) * captureScale
		scaledRect = RenderRect.fromRect(Rect.new(center - offset, center + offset))

		local cameraScale = captureScale / scaleOS
		if not (cameraScale.X == 1 and cameraScale.Y == 1) then
			local distortion = Camera.distort(camera, {
				anchor = Vector2.new(0.5, 0.5),
				position = Vector2.new(0.5, 0.5),
				size = cameraScale,
				zoom = 1,
				rotation = 0,
			})

			camera.CFrame = prevCameraCF * distortion
		end
	end

	local exported
	local success, err = pcall(function()
		local result = Photo.captureViewport(Sift.Dictionary.merge(options, {
			rect = scaledRect,
		}))

		exported = Photo.export(result, nil)
	end)

	camera.CFrame = prevCameraCF
	camera.CameraType = prevCameraType

	if not success then
		error(err)
	end

	return exported[1]
end

return {
	identifier = "captureViewport",
	callback = gt.wrapFn(typechecker, callback),
}


--!strict

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local CargoSemver = require(pluginRoot.Packages.CargoSemver)
local Semver = require(pluginRoot.Packages.Semver)
local gt = require(pluginRoot.Packages.GreenTea)

local Wally = require(pluginRoot.Wally)

-- stylua: ignore
local typechecker = gt.fn(
	gt.args(gt.string()),
	gt.returns(gt.boolean())
)

local function callback(version: string)
	if Semver.validateVersion(Wally.package.version) and Semver.validateVersion(version) then
		local wallyVersion = CargoSemver.Version.parse(Wally.package.version)
		local checkVersion = CargoSemver.Version.parse(version)
		return checkVersion.major == wallyVersion.major and checkVersion.minor >= wallyVersion.minor
	end
	return false
end

return {
	identifier = "compatibilityCheck",
	callback = gt.wrapFn(typechecker, callback),
}


--!strict

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local gt = require(pluginRoot.Packages.GreenTea)

local State = require(pluginRoot.Main.State)

-- stylua: ignore
local typechecker = gt.fn(
	gt.args(),
	gt.returns(gt.Vector2())
)

local function callback()
	return State.read().temporary.scaleOS
end

return {
	identifier = "getOSScale",
	callback = gt.wrapFn(typechecker, callback),
}


--!strict

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local gt = require(pluginRoot.Packages.GreenTea)

local Wally = require(pluginRoot.Wally)

-- stylua: ignore
local typechecker = gt.fn(
	gt.args(),
	gt.returns(gt.string())
)

local function callback()
	return Wally.package.version
end

return {
	identifier = "getVersion",
	callback = gt.wrapFn(typechecker, callback),
}


--!strict

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local gt = require(pluginRoot.Packages.GreenTea)

-- stylua: ignore
local typechecker = gt.fn(
	gt.args(),
	gt.returns(gt.boolean())
)

local function callback()
	return true
end

return {
	identifier = "isConnected",
	callback = gt.wrapFn(typechecker, callback),
}


--!strict

local pluginRoot = script:FindFirstAncestor("PluginRoot")
local gt = require(pluginRoot.Packages.GreenTea)

local SceneControl = require(pluginRoot.Main.Photo.SceneControl)

-- stylua: ignore
local typechecker = gt.fn(
	gt.args(),
	gt.returns()
)

local function callback()
	SceneControl.yieldUntilNextFrame()
end

return {
	identifier = "yieldUntilNextFrame",
	callback = gt.wrapFn(typechecker, callback),
}