import { Signal } from "@rbxts/beacon";
import { t } from "@rbxts/t";
import {
	ArgumentType,
	CommandCallback,
	CommandGuard,
	CommandOptions,
	GroupOptions,
	RegisterOptions,
	SharedConfig,
} from "../types";
import { ReadonlyDeep } from "../util/data";
import { CenturionLogger } from "../util/log";
import {
	BaseCommand,
	CommandGroup,
	CommandMetadata,
	ExecutableCommand,
} from "./command";
import { CommandContext } from "./context";
import { DecoratorMetadata, MetadataKey } from "./metadata";
import { ImmutableRegistryPath, RegistryPath } from "./path";

type Constructor = new (...args: never[]) => object;

const argTypeSchema = t.interface({
	name: t.string,
	expensive: t.boolean,
	transform: t.callback,
	suggestions: t.optional(t.callback),
});

function isArgumentType(value: unknown): value is ArgumentType<unknown> {
	return argTypeSchema(value);
}

export abstract class BaseRegistry<
	C extends ReadonlyDeep<SharedConfig> = ReadonlyDeep<SharedConfig>,
> {
	private static readonly ROOT_KEY = "__root__";
	private readonly loadModule: (module: ModuleScript) => unknown;

	protected readonly commands = new Map<string, BaseCommand>();
	protected readonly groups = new Map<string, CommandGroup>();
	protected readonly types = new Map<string, ArgumentType<unknown>>();
	protected readonly registeredObjects = new Set<object>();
	protected readonly logger: CenturionLogger;
	protected cachedPaths = new Map<string, RegistryPath[]>();
	protected globalGuards = new Array<CommandGuard>();

	readonly commandRegistered = new Signal<[command: BaseCommand]>();
	readonly groupRegistered = new Signal<[group: CommandGroup]>();
	readonly commandUnregistered = new Signal<[command: BaseCommand]>();
	readonly groupUnregistered = new Signal<[group: CommandGroup]>();

	constructor(protected readonly config: C) {
		this.logger = new CenturionLogger(config.logLevel, "Registry");
		this.globalGuards = [...config.guards];

		const tsImpl = (_G as Map<unknown, unknown>).get(script);
		this.loadModule = t.interface({
			import: t.callback,
		})(tsImpl)
			? (module) => tsImpl.import(script, module)
			: require;
	}

	init() {
		if (this.config.registerBuiltInTypes) {
			const builtInTypes =
				script.Parent?.Parent?.FindFirstChild("builtin")?.FindFirstChild(
					"types",
				);
			this.logger.assert(
				builtInTypes !== undefined,
				"Failed to locate built-in types",
			);
			this.load(builtInTypes);
		}
	}

	/**
	 * Loads all {@link ModuleScript} instances in the given instance.
	 *
	 * By default, only direct children of the container are loaded. If the `descendants`
	 * parameter is true, all descendants of the container will be loaded.
	 *
	 * If the {@link ModuleScript} returns a function, it will be called with the registry
	 * as an argument.
	 *
	 * @param container The container to iterate over
	 * @param descendants Whether to load descendants of the container.
	 */
	load(container: Instance, descendants = false) {
		const instances = descendants
			? container.GetDescendants()
			: container.GetChildren();

		for (const obj of instances) {
			if (!obj.IsA("ModuleScript")) continue;

			const [success, value] = pcall(() => this.loadModule(obj));
			if (!success) {
				this.logger.warn(
					`Failed to load module ${obj.GetFullName()}: ${value}`,
				);
				continue;
			}

			if (typeIs(value, "function")) {
				this.logger.debug(
					`Loaded module ${obj.GetFullName()}, calling returned function...`,
				);
				value(this);
			} else {
				this.logger.debug(`Loaded module ${obj.GetFullName()}`);
			}
		}
	}

	/**
	 * Registers any loaded commands and types that need to be registered.
	 *
	 * If the command/type has already been registered, it will be skipped.
	 */
	register() {
		const constructors: Constructor[] = [];
		for (const [obj] of DecoratorMetadata.metadata) {
			if (this.registeredObjects.has(obj)) continue;

			if (DecoratorMetadata.hasOwnMetadata(obj, MetadataKey.Register)) {
				constructors.push(obj as Constructor);
				continue;
			}

			if (
				DecoratorMetadata.hasOwnMetadata(obj, MetadataKey.Type) &&
				isArgumentType(obj)
			) {
				this.registerType(obj);
				this.registeredObjects.add(obj);
			}
		}

		for (const ctor of constructors) {
			this.registerCommandClass(ctor);
			this.registeredObjects.add(ctor);
		}
	}

	/**
	 * Registers a command with the given options and callback.
	 *
	 * Groups and guards can optionally be provided.
	 *
	 * @param options The command's options.
	 * @param callback The command callback.
	 * @param group The group to register the command under.
	 * @param guards The guards to apply to the command.
	 */
	registerCommand(
		options: CommandOptions,
		// biome-ignore lint/suspicious/noExplicitAny: Type checking is not possible for command callbacks and unknown is too restrictive in this case.
		callback: (ctx: CommandContext, ...args: any[]) => void,
		group?: string[],
		guards?: CommandGuard[],
	) {
		this.addCommand(
			this.createCommand(
				options,
				callback,
				group !== undefined ? new ImmutableRegistryPath(group) : undefined,
				guards,
			),
		);
	}

	/**
	 * Registers one or more argument types.
	 *
	 * @param types The types to register
	 */
	registerType(...types: ArgumentType<unknown>[]) {
		for (const options of types) {
			this.types.set(options.name, options);
			this.logger.debug(`Registered type: ${options.name}`);
		}
	}

	/**
	 * Registers one or more groups.
	 *
	 * @param groups The groups to register
	 */
	registerGroup(...groups: GroupOptions[]) {
		const commandGroups: CommandGroup[] = [];
		for (const group of groups) {
			const pathParts =
				group.parent !== undefined
					? group.parent.map((val) => val.lower())
					: [];
			pathParts.push(group.name.lower());
			commandGroups.push(
				new CommandGroup(
					this.config,
					new ImmutableRegistryPath(pathParts),
					group,
				),
			);
		}

		// Sort groups by path size so parent groups are registered first
		commandGroups.sort((a, b) => a.getPath().size() < b.getPath().size());

		for (const group of commandGroups) {
			const path = group.getPath();
			this.validatePath(path, false);

			const pathString = path.toString();
			if (group.getPath().size() > 1) {
				const parentPath = group.getPath().parent();
				const parentGroup = this.groups.get(parentPath.toString());
				if (parentGroup === undefined) {
					this.logger.error(
						`Parent group '${parentPath}' for group '${pathString}' is not registered`,
					);
					return;
				}

				if (parentGroup.hasGroup(group.options.name)) {
					this.logger.warn(
						`Skipping duplicate child group in ${parentPath}: ${group.options.name}`,
					);
					continue;
				}

				parentGroup.addGroup(group);
			}

			this.groups.set(pathString, group);
			this.cachePath(group.getPath());
			this.groupRegistered.Fire(group);
			this.logger.debug(`Registered group: ${pathString}`);
		}
	}

	/**
	 * Unregisters a command with the given path.
	 *
	 * @param path The path of the command to unregister.
	 */
	unregisterCommand(path: RegistryPath) {
		const command = this.getCommand(path);
		this.logger.assert(
			command !== undefined,
			`Command is not registered: ${path}`,
		);

		const group = this.getGroup(path.parent());
		if (group !== undefined) {
			group.removeCommand(command);
		}

		for (const path of command.getPaths()) {
			this.commands.delete(path.toString());
			this.uncachePath(path);
		}

		this.commandUnregistered.Fire(command);
		this.logger.debug(`Unregistered command: ${command.getPath()}`);
	}

	/**
	 * Unregisters a group and all of its child commands or groups.
	 *
	 * @param path The path of the group to unregister.
	 */
	unregisterGroup(path: RegistryPath) {
		const group = this.getGroup(path);
		this.logger.assert(group !== undefined, `Group is not registered: ${path}`);

		const parentGroup = this.getGroup(path.parent());
		if (parentGroup !== undefined) {
			parentGroup.removeGroup(group);
		}

		for (const command of group.getCommands()) {
			this.unregisterCommand(command.getPath());
		}

		for (const childGroup of group.getGroups()) {
			this.unregisterGroup(childGroup.getPath());
		}

		this.groups.delete(path.toString());
		this.uncachePath(path);
		this.groupUnregistered.Fire(group);
		this.logger.debug(`Unregistered group: ${group.getPath()}`);
	}

	/**
	 * Returns a registered argument type with the given name.
	 *
	 * @param name The name of the type.
	 * @returns An {@link ArgumentType}, or `undefined` if no type with the given name is registered.
	 */
	getType(name: string) {
		return this.types.get(name);
	}

	/**
	 * Returns a registered command with the given path.
	 *
	 * @param path The command's path.
	 * @returns A {@link BaseCommand}, or `undefined` if no command with the given path is registered.
	 */
	getCommand(path: RegistryPath) {
		return this.commands.get(path.toString());
	}

	/**
	 * Returns a registered command with the given path as a string.
	 *
	 * @param path The command's path as a string.
	 * @returns A {@link BaseCommand}, or `undefined` if no command with the given path is registered.
	 */
	getCommandByString(path: string) {
		return this.commands.get(path);
	}

	/**
	 * Returns all registered commands.
	 *
	 * @returns An array of {@link BaseCommand} instances.
	 */
	getCommands() {
		const commands: BaseCommand[] = [];
		for (const [_, command] of this.commands) {
			commands.push(command);
		}
		return commands;
	}

	/**
	 * Returns a registered group with the given path.
	 *
	 * @param path The group's path.
	 * @returns A {@link CommandGroup}, or `undefined` if no group with the given path is registered.
	 */
	getGroup(path: RegistryPath) {
		return this.groups.get(path.toString());
	}

	/**
	 * Returns a registered group with the given path as a string.
	 *
	 * @param path The group's path as a string.
	 * @returns A {@link CommandGroup}, or `undefined` if no group with the given path is registered.
	 */
	getGroupByString(path: string) {
		return this.groups.get(path);
	}

	/**
	 * Returns all registered groups.
	 *
	 * @returns An array of {@link CommandGroup} instances.
	 */
	getGroups() {
		const groups: CommandGroup[] = [];
		for (const [_, group] of this.groups) {
			groups.push(group);
		}
		return groups;
	}

	/**
	 * Returns all registered types.
	 *
	 * @returns An array of {@link ArgumentType} objects.
	 */
	getTypes() {
		const types: ArgumentType<unknown>[] = [];
		for (const [_, typeObject] of this.types) {
			types.push(typeObject);
		}
		return types;
	}

	/**
	 * Returns all registered root paths - paths made up of a single part.
	 *
	 * @returns An array of {@linkeRegistryPath} instances.
	 */
	getRootPaths() {
		return this.cachedPaths.get(BaseRegistry.ROOT_KEY) ?? [];
	}

	/**
	 * Returns all paths that are children of the given path.
	 *
	 * @param path The path to get the children of.
	 * @returns An array of {@link RegistryPath} instances.
	 */
	getChildPaths(path: RegistryPath) {
		return this.cachedPaths.get(path.toString().lower()) ?? [];
	}

	protected cachePath(path: RegistryPath) {
		let key = BaseRegistry.ROOT_KEY;
		for (const i of $range(0, path.size() - 1)) {
			const pathSlice = path.slice(0, i);

			const cache = this.cachedPaths.get(key) ?? [];
			this.cachedPaths.set(key, cache);
			key = pathSlice.toString();

			if (cache.some((val) => val.equals(pathSlice))) continue;
			cache.push(pathSlice);
			cache.sort((a, b) => a.tail() < b.tail());
		}
	}

	protected uncachePath(path: RegistryPath) {
		let key = BaseRegistry.ROOT_KEY;
		for (const i of $range(0, path.size() - 1)) {
			const cache = this.cachedPaths.get(key);
			if (cache === undefined) break;

			const pathSlice = path.slice(0, i);

			const newCache = cache
				.filter((val) => !val.equals(pathSlice))
				.sort((a, b) => a.tail() < b.tail());

			if (newCache.isEmpty()) {
				this.cachedPaths.delete(key);
			} else {
				this.cachedPaths.set(key, newCache);
			}
			key = pathSlice.toString();
		}
	}

	protected addCommand(command: BaseCommand, group?: CommandGroup) {
		for (const path of command.getPaths()) {
			this.validatePath(path, true);
			this.commands.set(path.toString(), command);
			this.cachePath(path);
		}

		if (group !== undefined) {
			group.addCommand(command);
		}

		this.commandRegistered.Fire(command);
		this.logger.debug(`Registered command: ${command.getPath()}`);
	}

	private createCommand(
		options: CommandOptions,
		callback: CommandCallback,
		group?: ImmutableRegistryPath,
		guards: CommandGuard[] = [],
		metadata?: CommandMetadata,
	) {
		const commandPath =
			group !== undefined
				? group.append(options.name.lower())
				: new ImmutableRegistryPath([options.name.lower()]);

		let commandGroup: CommandGroup | undefined;
		if (group !== undefined) {
			commandGroup = this.getGroup(group);
			this.logger.assert(
				commandGroup !== undefined,
				`Cannot assign group '${group}' to command '${commandPath}' as it is not registered`,
			);
		}

		return new ExecutableCommand(
			this.config,
			this,
			commandPath,
			options,
			callback,
			guards !== undefined ? [...guards] : [],
			metadata,
		);
	}

	private registerCommandClass(commandClass: Constructor) {
		const registerOptions = DecoratorMetadata.getOwnMetadata<RegisterOptions>(
			commandClass,
			MetadataKey.Register,
		);
		this.logger.assert(
			registerOptions !== undefined,
			`Metadata not found for @Register: ${commandClass}`,
		);

		if (registerOptions.groups !== undefined) {
			this.registerGroup(...registerOptions.groups);
		}

		const classGroups =
			DecoratorMetadata.getOwnMetadata<string[]>(
				commandClass,
				MetadataKey.Group,
			) ?? [];

		const classGuards =
			DecoratorMetadata.getOwnMetadata<CommandGuard[]>(
				commandClass,
				MetadataKey.Guard,
			) ?? [];

		const instance = this.config.construct(commandClass);
		for (const property of DecoratorMetadata.getOwnProperties(commandClass)) {
			// Get decorator data
			const options = DecoratorMetadata.getOwnMetadata<CommandOptions>(
				commandClass,
				MetadataKey.Command,
				property,
			);
			this.logger.assert(
				options !== undefined,
				`Metadata not found for @Command: ${commandClass}/${property}`,
			);

			const group = DecoratorMetadata.getOwnMetadata<string[]>(
				commandClass,
				MetadataKey.Group,
				property,
			);

			const guards =
				DecoratorMetadata.getOwnMetadata<CommandGuard[]>(
					commandClass,
					MetadataKey.Guard,
					property,
				) ?? [];

			const groupParts = classGroups.map((val) => val.lower());
			if (group !== undefined && !group.isEmpty()) {
				for (const part of group) {
					groupParts.push(part.lower());
				}
			}

			const callback = (commandClass as never as Record<string, unknown>)[
				property
			] as Callback;
			this.addCommand(
				this.createCommand(
					options,
					(ctx, ...args) => callback(instance, ctx, ...args),
					!groupParts.isEmpty()
						? new ImmutableRegistryPath(groupParts)
						: undefined,
					[...this.globalGuards, ...classGuards, ...guards],
					{
						ctor: commandClass,
						instance,
						property,
					},
				),
			);
		}
	}

	private validatePath(path: RegistryPath, isCommand: boolean) {
		const pathString = path.toString();
		for (const part of path.iter()) {
			if (part.match("^[a-zA-Z0-9_]+$")[0]) continue;
			this.logger.error(
				`Invalid path for ${isCommand ? "command" : "group"}: ${pathString}. Command/group names can only contain alphanumeric characters and underscores.`,
			);
			return;
		}

		const commandRegistered = this.commands.has(pathString);
		if (commandRegistered && isCommand) {
			this.logger.error(`Duplicate command: ${pathString}`);
			return;
		}

		if (commandRegistered) {
			this.logger.error(
				`A command already exists with the same name as this group: ${pathString}`,
			);
			return;
		}

		const groupRegistered = this.groups.has(pathString);
		if (groupRegistered && isCommand) {
			this.logger.error(
				`A group already exists with the same name as this command: ${pathString}`,
			);
			return;
		}

		if (groupRegistered) {
			this.logger.error(`Duplicate group: ${pathString}`);
		}
	}
}


import { RunService } from "@rbxts/services";
import { CommandContextData, CommandOptions, GroupOptions } from "./types";

type NetworkInstances = Pick<
	CreatableInstances,
	"RemoteEvent" | "RemoteFunction"
>;

function getRemote<T extends keyof NetworkInstances>(
	className: T,
	name: string,
): NetworkInstances[T] {
	if (RunService.IsServer()) {
		let container = script.Parent?.FindFirstChild("remotes");
		if (container === undefined) {
			container = new Instance("Folder");
			container.Name = "remotes";
			container.Parent = script.Parent;
		}

		const remote = new Instance(className);
		remote.Name = name;
		remote.Parent = container;
		return remote;
	}

	const instance = script.Parent?.WaitForChild("remotes");
	assert(
		instance !== undefined && classIs(instance, "Folder"),
		"Remotes folder not found",
	);
	const container = instance;

	const remote = container.WaitForChild(name);
	assert(classIs(remote, className), `Remote '${name}' not found`);
	return remote;
}

export interface SyncData {
	commands: Map<string, CommandOptions>;
	groups: Map<string, GroupOptions>;
}

export function getRemotes() {
	return {
		syncStart: getRemote("RemoteEvent", "SyncStart") as RemoteEvent<() => void>,
		syncDispatch: getRemote("RemoteEvent", "SyncDispatch") as RemoteEvent<
			(data: SyncData) => void
		>,
		execute: getRemote("RemoteFunction", "Execute") as RemoteFunction<
			(path: string, args: string[]) => CommandContextData
		>,
	};
}


import { CenturionClient } from "@rbxts/centurion";
import { splitString } from "@rbxts/centurion/out/shared/util/string";
import { UserInputService } from "@rbxts/services";
import Vide, { derive } from "@rbxts/vide";
import { Suggestions, Terminal } from "../components";
import { Group } from "../components/ui/group";
import { Layer } from "../components/ui/layer";
import { useClient } from "../hooks/use-client";
import { useEvent } from "../hooks/use-event";
import { px, usePx } from "../hooks/use-px";
import { mouseOverInterface, options, terminalText, visible } from "../store";

const MOUSE_INPUT_TYPES = new Set<Enum.UserInputType>([
	Enum.UserInputType.MouseButton1,
	Enum.UserInputType.MouseButton2,
	Enum.UserInputType.Touch,
]);

export function CenturionApp(client: CenturionClient) {
	useClient(client);
	usePx();

	const validKeys = derive(() => new Set(options().activationKeys));
	const terminalTextParts = derive(() => {
		return splitString(terminalText(), " ", true);
	});

	useEvent(UserInputService.InputBegan, (input, gameProcessed) => {
		if (validKeys().has(input.KeyCode) && !gameProcessed) {
			visible(!visible());
		} else if (
			options().hideOnLostFocus &&
			MOUSE_INPUT_TYPES.has(input.UserInputType) &&
			!mouseOverInterface()
		) {
			visible(false);
		}
	});

	return (
		<Layer
			name="Centurion"
			displayOrder={() => options().displayOrder}
			visible={visible}
		>
			<Group
				anchor={() => options().anchor}
				size={() => {
					const size = options().size;
					return typeIs(size, "UDim2") ? size : size(px);
				}}
				position={() => {
					const position = options().position;
					return typeIs(position, "UDim2") ? position : position(px);
				}}
			>
				<Terminal textParts={terminalTextParts} />
				<Suggestions textParts={terminalTextParts} />

				<uilistlayout
					Padding={() => new UDim(0, px(8))}
					SortOrder={"LayoutOrder"}
				/>
			</Group>
		</Layer>
	);
}


import { HistoryEntry } from "@rbxts/centurion";
import Vide, { Derivable, derive } from "@rbxts/vide";
import { HISTORY_TEXT_SIZE } from "../../constants/text";
import { px } from "../../hooks/use-px";
import { options } from "../../store";
import { Frame } from "../ui/frame";
import { Group } from "../ui/group";
import { Outline } from "../ui/outline";
import { Text } from "../ui/text";
import { TextField } from "../ui/text-field";

interface HistoryLineProps {
	data: HistoryEntry;
	position?: Derivable<UDim2>;
	order?: Derivable<number>;
}

export function HistoryLine({ data, position, order }: HistoryLineProps) {
	const date = derive(() => {
		const dateTime = DateTime.fromUnixTimestamp(data.sentAt).FormatLocalTime(
			"LT",
			"en-us",
		);
		const dateParts = dateTime.split(" ");
		return `<b>${dateParts[0]}</b> ${dateParts[1]}`;
	});

	return (
		<Group
			automaticSize="Y"
			size={UDim2.fromScale(1, 0)}
			position={position}
			layoutOrder={order}
		>
			<Frame
				backgroundColor={() => options().palette.surface}
				size={() => UDim2.fromOffset(px(76), px(HISTORY_TEXT_SIZE + 4))}
				cornerRadius={() => new UDim(0, px(4))}
			>
				<Text
					size={UDim2.fromScale(1, 1)}
					text={date}
					textColor={() => options().palette.text}
					textSize={() => px(HISTORY_TEXT_SIZE)}
					richText={true}
				/>

				<Outline
					innerThickness={() => px(1)}
					innerTransparency={0.25}
					innerColor={() => {
						return data.success
							? options().palette.success
							: options().palette.error;
					}}
					outerThickness={0}
					cornerRadius={() => new UDim(0, px(4))}
				/>
			</Frame>

			<TextField
				automaticSize="Y"
				anchor={new Vector2(1, 0)}
				size={() => new UDim2(1, -px(84), 0, 0)}
				position={() => new UDim2(1, 0, 0, px(2))}
				text={data.text}
				textSize={() => px(HISTORY_TEXT_SIZE)}
				textColor={() => {
					const palette = options().palette;
					return data.success ? palette.text : palette.error;
				}}
				textEditable={false}
				textXAlignment="Left"
				clearTextOnFocus={false}
				font={() => options().font.medium}
				richText
			/>
		</Group>
	);
}


import { HistoryEntry } from "@rbxts/centurion";
import Vide, {
	Derivable,
	derive,
	effect,
	For,
	read,
	source,
} from "@rbxts/vide";
import { HISTORY_TEXT_SIZE } from "../../constants/text";
import { px } from "../../hooks/use-px";
import { options } from "../../store";
import { HistoryData, HistoryLineData } from "../../types";
import { ScrollingFrame } from "../ui/scrolling-frame";
import { HistoryLine } from "./history-line";

interface HistoryListProps {
	entries: Derivable<HistoryEntry[]>;
	size?: Derivable<UDim2>;
	position?: Derivable<UDim2>;
	scrollingEnabled?: Derivable<boolean>;
	onContentSizeChanged?: (size: Vector2) => void;
}

export function HistoryList({
	entries,
	size,
	position,
	scrollingEnabled,
	onContentSizeChanged,
}: HistoryListProps) {
	const ref = source<ScrollingFrame>();

	return (
		<ScrollingFrame
			automaticCanvasSize="Y"
			size={size}
			position={position}
			canvasSize={new UDim2()}
			action={ref}
			scrollBarColor={() => options().palette.subtext}
			scrollingEnabled={scrollingEnabled}
			scrollBarThickness={() => (read(scrollingEnabled) ? 10 : 0)}
			scrollingDirection="Y"
			native={{
				AbsoluteCanvasSizeChanged: (rbx) => {
					const frame = ref();
					if (frame === undefined) return;
					frame.CanvasPosition = new Vector2(0, rbx.Y);
				},
			}}
		>
			<For each={() => read(entries)}>
				{(entry: HistoryEntry, index: () => number) => {
					return <HistoryLine data={entry} order={index} />;
				}}
			</For>

			<uilistlayout
				Padding={() => new UDim(0, px(8))}
				SortOrder="LayoutOrder"
				AbsoluteContentSizeChanged={(rbx) => onContentSizeChanged?.(rbx)}
			/>
		</ScrollingFrame>
	);
}


import Vide, { Derivable, InstanceAttributes } from "@rbxts/vide";
import { px } from "../../hooks/use-px";
import { options } from "../../store";
import { Frame } from "../ui/frame";
import { Text } from "../ui/text";

interface BadgeProps {
	anchor?: Derivable<Vector2>;
	size?: Derivable<UDim2>;
	position?: Derivable<UDim2>;
	color?: Derivable<Color3>;
	text?: Derivable<string>;
	textColor?: Derivable<Color3>;
	textSize?: Derivable<number>;
	visible?: Derivable<boolean>;
	children?: Vide.Node;
	backgroundTransparency?: number;
	native?: InstanceAttributes<TextLabel>;
}

export function Badge(props: BadgeProps) {
	return (
		<Frame
			backgroundColor={props.color}
			cornerRadius={() => new UDim(0, px(4))}
			backgroundTransparency={props.backgroundTransparency}
			clipsDescendants
			anchor={props.anchor}
			position={props.position}
			size={props.size}
			visible={props.visible}
		>
			<Text
				text={props.text}
				textColor={props.textColor}
				textSize={props.textSize}
				textXAlignment="Center"
				size={UDim2.fromScale(1, 1)}
				font={() => options().font.bold}
				{...props.native}
			>
				{props.children}
			</Text>
		</Frame>
	);
}


import Vide, { Derivable, derive, read, source, spring } from "@rbxts/vide";
import {
	SUGGESTION_TEXT_SIZE,
	SUGGESTION_TITLE_TEXT_SIZE,
} from "../../constants/text";
import { px } from "../../hooks/use-px";
import { mouseOverInterface, options } from "../../store";
import { Suggestion } from "../../types";
import { Frame } from "../ui/frame";
import { Group } from "../ui/group";
import { Padding } from "../ui/padding";
import { Text } from "../ui/text";
import { highlightMatching } from "./util";

export interface MainSuggestionProps {
	suggestion: Derivable<Suggestion | undefined>;
	currentText?: Derivable<string | undefined>;
	onSizeChanged?: (size: UDim2) => void;
	onClick?: () => void;
	clickable?: Derivable<boolean>;
}

const MAX_WIDTH = 180;

export function MainSuggestion({
	suggestion,
	currentText,
	onSizeChanged,
	onClick,
	clickable,
}: MainSuggestionProps) {
	const titleBounds = source(new Vector2());
	const descriptionBounds = source(new Vector2());
	const errorBounds = source(new Vector2());
	const badgeBounds = source(new Vector2());
	const errorText = derive(() => {
		const currentSuggestion = read(suggestion);
		return currentSuggestion?.type === "argument"
			? currentSuggestion.error
			: undefined;
	});

	const windowSize = derive(() => {
		if (read(suggestion) === undefined) {
			const size = new UDim2();
			onSizeChanged?.(size);
			return size;
		}

		const titleSize = read(titleBounds);
		const descriptionSize = read(descriptionBounds);
		const errorSize = read(errorBounds);

		const width =
			math.max(titleSize.X, descriptionSize.X, errorSize.X) + badgeBounds().X;
		const height = titleSize.Y + descriptionSize.Y + errorSize.Y;

		const size = UDim2.fromOffset(width + px(8) * 2, height + px(8) * 2);
		onSizeChanged?.(size);
		return size;
	});

	return (
		<Frame
			size={spring(windowSize, 0.2)}
			backgroundColor={() => options().palette.background}
			backgroundTransparency={() => options().backgroundTransparency ?? 0}
			cornerRadius={() => new UDim(0, px(8))}
			native={{
				MouseEnter: () => mouseOverInterface(true),
				MouseLeave: () => mouseOverInterface(false),
			}}
		>
			<textbutton
				Visible={clickable}
				BackgroundTransparency={1}
				Text=""
				Size={UDim2.fromScale(1, 1)}
				Activated={onClick}
			/>

			<Group size={UDim2.fromScale(1, 1)}>
				<Padding all={() => new UDim(0, px(8))} />

				<Text
					text={() => {
						const currentSuggestion = read(suggestion);
						return currentSuggestion?.type === "argument"
							? currentSuggestion.title
							: highlightMatching(
									options().palette.highlight,
									currentSuggestion?.title,
									read(currentText),
								);
					}}
					textSize={() => px(SUGGESTION_TITLE_TEXT_SIZE)}
					textColor={() => options().palette.text}
					textXAlignment="Left"
					textYAlignment="Top"
					font={() => options().font.bold}
					richText
					size={() =>
						UDim2.fromOffset(titleBounds().X, px(SUGGESTION_TITLE_TEXT_SIZE))
					}
					native={{
						TextBoundsChanged: (bounds) => titleBounds(bounds),
					}}
				/>

				<Text
					text={() => read(suggestion)?.description ?? ""}
					textSize={() => px(SUGGESTION_TEXT_SIZE)}
					textColor={() => options().palette.subtext}
					textXAlignment="Left"
					textYAlignment="Top"
					textWrapped
					richText
					position={() => UDim2.fromOffset(0, px(SUGGESTION_TITLE_TEXT_SIZE))}
					size={() =>
						UDim2.fromOffset(
							px(MAX_WIDTH),
							math.max(descriptionBounds().Y, px(SUGGESTION_TEXT_SIZE)),
						)
					}
					native={{
						TextBoundsChanged: (rbx) => descriptionBounds(rbx),
					}}
				/>

				<Text
					text={() => errorText() ?? ""}
					textColor={() => options().palette.error}
					textSize={() => px(SUGGESTION_TEXT_SIZE)}
					textTransparency={spring(() => {
						return errorText() !== undefined ? 0 : 1;
					}, 0.2)}
					textXAlignment="Left"
					textWrapped
					automaticSize="Y"
					anchor={new Vector2(0, 1)}
					position={UDim2.fromScale(0, 1)}
					size={() => UDim2.fromOffset(px(MAX_WIDTH), 0)}
					native={{
						TextBoundsChanged: (bounds) => {
							errorBounds(errorText() !== undefined ? bounds : Vector2.zero);
						},
					}}
				/>

				<Frame
					backgroundColor={() => options().palette.highlight}
					cornerRadius={() => new UDim(0, px(4))}
					clipsDescendants
					visible={() => {
						const currentSuggestion = read(suggestion);
						return (
							currentSuggestion !== undefined &&
							currentSuggestion.type === "argument"
						);
					}}
					anchor={new Vector2(1, 0)}
					position={UDim2.fromScale(1, 0)}
					size={spring(() => {
						return UDim2.fromOffset(
							badgeBounds().X + px(4),
							px(SUGGESTION_TITLE_TEXT_SIZE),
						);
					}, 0.2)}
				>
					<Text
						text={() => {
							const currentSuggestion = read(suggestion);
							return currentSuggestion !== undefined &&
								currentSuggestion.type === "argument"
								? currentSuggestion.dataType
								: "";
						}}
						textColor={() => options().palette.surface}
						textSize={() => px(SUGGESTION_TEXT_SIZE)}
						textXAlignment="Center"
						font={() => options().font.bold}
						size={UDim2.fromScale(1, 1)}
						native={{
							TextBoundsChanged: (bounds) => badgeBounds(bounds),
						}}
					/>
				</Frame>
			</Group>
		</Frame>
	);
}


import { ArrayUtil } from "@rbxts/centurion/out/shared/util/data";
import Vide, { Derivable, derive, For, read } from "@rbxts/vide";
import { SUGGESTION_TEXT_SIZE } from "../../constants/text";
import { px } from "../../hooks/use-px";
import { mouseOverInterface, options } from "../../store";
import { Suggestion } from "../../types";
import { Group } from "../ui/group";
import { Padding } from "../ui/padding";
import { Text } from "../ui/text";
import { highlightMatching } from "./util";

export interface SuggestionListProps {
	suggestion?: Derivable<Suggestion | undefined>;
	currentText?: Derivable<string | undefined>;
	onClick?: (text: string) => void;
	size: Derivable<UDim2>;
	textParts: Derivable<string[]>;
}

const MAX_SUGGESTIONS = 3;

export function SuggestionList({
	suggestion,
	currentText,
	onClick,
	size,
}: SuggestionListProps) {
	const suggestions = derive(() => {
		return ArrayUtil.slice(read(suggestion)?.others ?? [], 0, MAX_SUGGESTIONS);
	});

	return (
		<Group
			size={size}
			native={{
				MouseEnter: () => mouseOverInterface(true),
				MouseLeave: () => mouseOverInterface(false),
			}}
		>
			<uilistlayout
				SortOrder="LayoutOrder"
				Padding={() => new UDim(0, px(8))}
			/>

			<For each={suggestions}>
				{(name: string, i: () => number) => {
					return (
						<textbutton
							Size={() => new UDim2(1, 0, 0, px(SUGGESTION_TEXT_SIZE + 6))}
							BackgroundColor3={() => options().palette.background}
							BackgroundTransparency={() =>
								options().backgroundTransparency ?? 0
							}
							Text=""
							ClipsDescendants={true}
							LayoutOrder={i}
							Activated={() => onClick?.(name)}
						>
							<Padding all={() => new UDim(0, px(4))} />
							<uicorner CornerRadius={() => new UDim(0, px(8))} />

							<Text
								size={() => new UDim2(1, 0, 1, 0)}
								text={() =>
									highlightMatching(
										options().palette.highlight,
										name,
										read(currentText),
									)
								}
								textColor={() => options().palette.text}
								textSize={() => px(SUGGESTION_TEXT_SIZE)}
								textXAlignment="Left"
								richText={true}
							/>
						</textbutton>
					);
				}}
			</For>
		</Group>
	);
}


import { TextService } from "@rbxts/services";
import Vide, {
	cleanup,
	Derivable,
	derive,
	read,
	source,
	spring,
} from "@rbxts/vide";
import { SUGGESTION_TEXT_SIZE } from "../../constants/text";
import { useClient } from "../../hooks/use-client";
import { px } from "../../hooks/use-px";
import { updateText } from "../../hooks/use-text-box";
import { currentSuggestion, currentTextPart, terminalText } from "../../store";
import {
	completeArgument,
	completeCommand,
} from "../terminal/terminal-text-field/suggestion";
import { Group } from "../ui/group";
import { MainSuggestion } from "./main-suggestion";
import { SuggestionList } from "./suggestion-list";

const PADDING = 8;

interface SuggestionsProps {
	textParts: Derivable<string[]>;
}

export function Suggestions({ textParts }: SuggestionsProps) {
	const client = useClient();
	const mainSize = source(new UDim2());

	const listBoundsParams = new Instance("GetTextBoundsParams");
	listBoundsParams.RichText = true;
	cleanup(listBoundsParams);

	const listBounds = derive(() => {
		let width = 0;

		const suggestions = currentSuggestion()?.others ?? [];
		for (const value of suggestions) {
			listBoundsParams.Text = value;
			const suggestionBounds = TextService.GetTextBoundsAsync(listBoundsParams);
			width = math.max(width, suggestionBounds.X);
		}

		const height =
			suggestions.size() * px(SUGGESTION_TEXT_SIZE + 6) +
			(suggestions.size() - 1) * px(4) +
			px(8);

		return new Vector2(width, height);
	});

	return (
		<Group automaticSize="XY" size={UDim2.fromOffset(0, 0)}>
			<MainSuggestion
				suggestion={currentSuggestion}
				currentText={currentTextPart}
				onSizeChanged={mainSize}
				onClick={() => {
					const suggestion = currentSuggestion();
					if (suggestion === undefined) return;

					if (suggestion.type === "command") {
						updateText(
							completeCommand(
								client.registry,
								terminalText(),
								read(textParts),
								suggestion.title,
							),
						);
					}
				}}
				clickable={() => currentSuggestion()?.type === "command"}
			/>

			<SuggestionList
				suggestion={currentSuggestion}
				currentText={currentTextPart}
				onClick={(text) => {
					updateText(completeArgument(terminalText(), text, 1, false));
				}}
				textParts={textParts}
				size={spring(
					() =>
						new UDim2(
							0,
							math.max(listBounds().X, mainSize().X.Offset),
							0,
							listBounds().Y,
						),
					0.3,
				)}
			/>

			<uilistlayout Padding={() => new UDim(0, px(PADDING))} />
		</Group>
	);
}


import {
	ArgumentOptions,
	BaseRegistry,
	CommandOptions,
	ListArgumentType,
	RegistryPath,
	SingleArgumentType,
} from "@rbxts/centurion";
import { ReadonlyDeep } from "@rbxts/centurion/out/shared/util/data";
import { Players } from "@rbxts/services";
import { ArgumentSuggestion, CommandSuggestion } from "../../types";
import { containsSpace, getQuoteChar } from "../../utils/string";

export interface SingleArgument {
	kind: "single";
	options: ReadonlyDeep<ArgumentOptions>;
	type: SingleArgumentType<unknown>;
	input?: string;
}

export interface ListArgument {
	kind: "list";
	options: ReadonlyDeep<ArgumentOptions>;
	type: ListArgumentType<unknown>;
	input?: string[];
}

export type Argument = SingleArgument | ListArgument;

function getMatches(
	strings: string[],
	text?: string,
): [number, string, number][] {
	if (text === undefined) {
		return strings.sort().map((str, i) => {
			const formatted = containsSpace(str) ? `"${str}"` : str;
			return [i, formatted, formatted.size()];
		});
	}

	const textLower = text.lower();
	const textEndIndex = text.size();
	const quoteChar = getQuoteChar(text);
	const quoted = quoteChar !== undefined;

	const results: [number, string, number][] = [];
	for (const i of $range(0, strings.size() - 1)) {
		let str = strings[i];
		if (quoted) {
			str = `${quoteChar}${str}${quoteChar}`;
		} else if (containsSpace(str)) {
			str = `"${str}"`;
		}

		const part = str.lower().sub(0, textEndIndex);
		if (part === textLower) {
			results.push([i, str, str.size()]);
		}
	}
	return results.sort((a, b) => a[1] < b[1]);
}

export function getArgumentSuggestion(arg: Argument, textPart?: string) {
	const suggestions =
		arg.options.suggestions !== undefined ? [...arg.options.suggestions] : [];
	const singleArg = arg.kind === "single";

	const typeSuggestions = singleArg
		? arg.type.suggestions?.(arg.input ?? "", Players.LocalPlayer)
		: arg.type.suggestions?.(arg.input ?? [], Players.LocalPlayer);
	if (typeSuggestions !== undefined) {
		for (const text of typeSuggestions) {
			suggestions.push(text);
		}
	}

	let errorText: string | undefined;
	const input = arg.input;
	if (!arg.type.expensive && input !== undefined) {
		const [success, err] = pcall(() => {
			const transformResult = singleArg
				? arg.type.transform(input as string, Players.LocalPlayer)
				: arg.type.transform(input as string[], Players.LocalPlayer);
			if (transformResult.ok) return;
			errorText = transformResult.value;
		});

		if (!success) {
			errorText = "Failed to transform argument";
			warn(err);
		}
	}

	return {
		type: "argument",
		title: arg.options.name,
		others: getMatches(suggestions, textPart).map(([, str]) => str),
		description: arg.options.description,
		dataType: arg.type.name,
		optional: arg.options.optional ?? false,
		error: errorText,
	} satisfies ArgumentSuggestion;
}

export function getCommandSuggestion(
	registry: BaseRegistry,
	parentPath?: RegistryPath,
	text?: string,
): CommandSuggestion | undefined {
	const paths =
		parentPath !== undefined
			? registry.getChildPaths(parentPath)
			: registry.getRootPaths();
	if (paths.isEmpty()) return;

	const pathNames = paths.map((path) => path.tail());
	const sortedPaths = getMatches(pathNames, text);
	const firstMatch = sortedPaths.remove(0);
	if (firstMatch === undefined) return;

	const firstPath = paths[firstMatch[0]];

	const command = registry.getCommand(firstPath);
	if (command !== undefined) {
		return {
			type: "command",
			title: getCommandName(
				command.options as CommandOptions,
				firstPath.tail(),
			),
			description: command.options.description,
			others: sortedPaths.map(([, str]) => str),
			shortcuts: (command.options as CommandOptions).shortcuts,
		};
	}

	const group = registry.getGroup(firstPath);
	if (group !== undefined) {
		return {
			type: "command",
			title: group.options.name,
			description: group.options.description,
			others: sortedPaths.map(([, str]) => str),
		};
	}
}

function getCommandName(options: CommandOptions, input: string) {
	if (input === options.name.lower()) return options.name;

	for (const alias of options.aliases ?? []) {
		if (input === alias.lower()) return alias;
	}
	return input;
}


import { BaseRegistry } from "@rbxts/centurion";
import {
	commandArgIndex,
	currentCommandPath,
	currentTextPart,
	terminalArgIndex,
} from "../../../store";
import { Suggestion } from "../../../types";
import { formatPartsAsPath, getArgumentNames } from "../../../utils/command";

function replaceTextPart(text: string, ...suggestions: string[]) {
	const currentPart = currentTextPart();
	const currentPartSize = currentPart?.size() ?? 0;

	let result = text.sub(0, text.size() - currentPartSize);
	result += suggestions.join(" ");
	return result;
}

export function completeCommand(
	registry: BaseRegistry,
	text: string,
	textParts: string[],
	suggestion: string,
) {
	const atNextPart = text.sub(-1) === " ";
	const pathParts = [...textParts];
	if (!atNextPart) {
		pathParts.remove(textParts.size() - 1);
	}
	pathParts.push(suggestion);

	const command = registry.getCommandByString(
		formatPartsAsPath(pathParts),
	)?.options;

	let result = replaceTextPart(text, suggestion);

	// Add a space if this path isn't a command (meaning it's a group) or is a command with arguments
	if (command === undefined || !(command.arguments?.isEmpty() ?? true)) {
		result += " ";
	}

	return result;
}

export function completeArgument(
	text: string,
	suggestion: string,
	commandArgCount: number,
	trailingSpace = true,
) {
	const argIndex = terminalArgIndex();
	if (argIndex === undefined || commandArgCount === 0) return text;

	let result = replaceTextPart(text, suggestion);
	print(argIndex, commandArgCount);
	if (trailingSpace && argIndex < commandArgCount - 1) {
		print("Adding trailing space");
		result += " ";
	}
	return result;
}

export function getSuggestedText(
	registry: BaseRegistry,
	text: string,
	textParts: string[],
	atNextPart: boolean,
	suggestion?: Suggestion,
) {
	if (suggestion === undefined) return "";

	let suggestionText: string;

	if (textParts.isEmpty() || suggestion.type === "command") {
		suggestionText = suggestion.title;
	} else {
		const command = currentCommandPath();
		const argIndex = terminalArgIndex();

		if (command === undefined || argIndex === undefined) return "";

		if (atNextPart && argIndex === commandArgIndex()) {
			suggestionText = suggestion.title;
		} else if (!suggestion.others.isEmpty()) {
			suggestionText = suggestion.others[0];
		} else {
			suggestionText = currentTextPart() ?? "";
		}

		const argNames = getArgumentNames(registry, command);

		for (const i of $range(argIndex + 1, argNames.size() - 1)) {
			suggestionText += ` ${argNames[i]}`;
		}
	}

	const partSize = currentTextPart()?.size() ?? 0;

	let result = text;
	result += suggestionText.sub(partSize + 1);
	return result;
}


import { UserInputService } from "@rbxts/services";
import Vide, { Derivable, effect, read, source } from "@rbxts/vide";
import { useClient } from "../../../hooks/use-client";
import { useEvent } from "../../../hooks/use-event";
import { px } from "../../../hooks/use-px";
import { updateText, useTextBox } from "../../../hooks/use-text-box";
import {
	currentCommandPath,
	currentSuggestion,
	options,
	terminalTextValid,
	visible,
} from "../../../store";
import { Frame } from "../../ui/frame";
import { Padding } from "../../ui/padding";
import { Text } from "../../ui/text";
import { TextField } from "../../ui/text-field";
import {
	completeArgument,
	completeCommand,
	getSuggestedText,
} from "./suggestion";

interface TerminalTextFieldProps {
	anchor?: Derivable<Vector2>;
	size?: Derivable<UDim2>;
	position?: Derivable<UDim2>;
	backgroundTransparency?: Derivable<number>;
	onTextChange?: (text: string) => void;
	onSubmit?: (text: string) => void;
	textParts: Derivable<string[]>;
	atNextPart: () => boolean;
}

const TEXT_SIZE = 22;

export function TerminalTextField({
	anchor,
	size,
	position,
	backgroundTransparency,
	onTextChange,
	onSubmit,
	textParts,
	atNextPart,
}: TerminalTextFieldProps) {
	const client = useClient();

	const ref = source<TextBox>();

	const commandHistory = source<string[]>([]);
	const commandHistoryIndex = source<number | undefined>(undefined);
	const suggestionText = source("");
	let currentTextValue = "";
	let onScreenKeyboard = false;

	// Focus text field when terminal becomes visible
	effect(() => {
		if (visible()) {
			ref()?.CaptureFocus();
		} else {
			ref()?.ReleaseFocus();
		}
	});

	effect(() => {
		const textBox = ref();
		if (textBox === undefined) return;
		useTextBox(textBox);
	});

	const traverseHistory = (up: boolean) => {
		const history = commandHistory();
		if (history.isEmpty()) return;

		const historyIndex = commandHistoryIndex();
		if (!up && historyIndex === undefined) return;

		const textBox = ref();
		if (textBox === undefined) return;

		const lastIndex = history.size() - 1;
		if (!up && historyIndex === lastIndex) {
			updateText("");
			commandHistoryIndex(undefined);
			return;
		}

		const newIndex = math.clamp(
			(historyIndex ?? history.size()) + (up ? -1 : 1),
			0,
			lastIndex,
		);

		updateText(history[newIndex]);
		commandHistoryIndex(newIndex);
	};

	effect(() => {
		suggestionText(
			getSuggestedText(
				client.registry,
				ref()?.Text ?? "",
				read(textParts),
				atNextPart(),
				currentSuggestion(),
			),
		);
	});

	useEvent(UserInputService.InputBegan, (input) => {
		const textBox = ref();
		if (textBox === undefined || !textBox?.IsFocused()) return;

		// Handle history traversal
		if (input.KeyCode === Enum.KeyCode.Up) {
			traverseHistory(true);
		} else if (input.KeyCode === Enum.KeyCode.Down) {
			traverseHistory(false);
		}

		if (input.KeyCode !== Enum.KeyCode.Tab) return;

		// Handle autocompletion
		const commandPath = currentCommandPath();
		const suggestion = currentSuggestion();
		if (suggestion === undefined) return;

		const currentText = textBox.Text;

		if (commandPath === undefined) {
			updateText(
				completeCommand(
					client.registry,
					textBox.Text,
					read(textParts),
					suggestion.title,
				),
			);
			return;
		}

		const argCount =
			client.registry.getCommand(commandPath)?.options.arguments?.size() ?? 0;
		if (suggestion.type === "command" && argCount !== 0) {
			updateText(`${currentText} `);
			return;
		}

		if (suggestion.others.isEmpty()) {
			updateText(currentText);
			return;
		}

		updateText(completeArgument(currentText, suggestion.others[0], argCount));
	});

	return (
		<Frame
			anchor={anchor}
			size={size}
			position={position}
			backgroundColor={() => options().palette.surface}
			backgroundTransparency={backgroundTransparency}
			cornerRadius={() => new UDim(0, px(4))}
		>
			<Padding all={() => new UDim(0, px(8))} />

			<TextField
				action={ref}
				size={UDim2.fromScale(1, 1)}
				textSize={() => px(TEXT_SIZE)}
				textColor={() => {
					return terminalTextValid()
						? options().palette.success
						: options().palette.error;
				}}
				textXAlignment="Left"
				placeholderText="Enter command..."
				placeholderColor={() => options().palette.subtext}
				font={() => options().font.medium}
				clearTextOnFocus={false}
				native={{
					FocusLost: (enterPressed) => {
						if (!enterPressed) return;

						const textBox = ref();
						if (textBox === undefined) return;

						const currentText = textBox.Text;
						const history = commandHistory();
						if (
							currentText !== "" &&
							(history.isEmpty() || history[history.size() - 1] !== currentText)
						) {
							commandHistory([...history, textBox.Text]);
						}
						commandHistoryIndex(undefined);
						onSubmit?.(currentText);
						textBox.Text = "";

						if (!onScreenKeyboard) {
							textBox.CaptureFocus();
						}
						onScreenKeyboard = false;
					},
					TextChanged: (currentText) => {
						const textBox = ref();
						if (textBox === undefined) return;

						const historyIndex = commandHistoryIndex();
						if (
							historyIndex !== undefined &&
							currentText !== commandHistory()[historyIndex]
						) {
							commandHistoryIndex(undefined);
						}

						// Remove line breaks
						let value = currentText;
						if (value.match("[\n\r]")[0] !== undefined) {
							value = value.gsub("[\n\r]", "")[0];
						}

						// Remove all tabs from text input - we use these for autocompletion
						if (value.match("\t")[0] !== undefined) {
							value = value.gsub("\t", "")[0];
						}

						textBox.Text = value;

						if (currentTextValue === value) return;
						currentTextValue = value;
						onTextChange?.(value);
					},
					ReturnPressedFromOnScreenKeyboard: () => {
						const textBox = ref();
						if (textBox === undefined) return;

						task.wait();
						onScreenKeyboard = true;
						textBox.ReleaseFocus(true);
					},
				}}
				zIndex={2}
			/>

			<Text
				size={UDim2.fromScale(1, 1)}
				text={suggestionText}
				textColor={() => options().palette.subtext}
				textSize={() => px(TEXT_SIZE)}
				textXAlignment="Left"
				font={() => options().font.medium}
			/>
		</Frame>
	);
}


import { ArgumentOptions, HistoryEntry, RegistryPath } from "@rbxts/centurion";
import { ArrayUtil, ReadonlyDeep } from "@rbxts/centurion/out/shared/util/data";
import { splitString } from "@rbxts/centurion/out/shared/util/string";
import Vide, { Derivable, derive, read, source, spring } from "@rbxts/vide";
import { HISTORY_TEXT_SIZE } from "../../constants/text";
import { useClient } from "../../hooks/use-client";
import { useEvent } from "../../hooks/use-event";
import { px } from "../../hooks/use-px";
import {
	commandArgIndex,
	currentCommandPath,
	currentSuggestion,
	currentTextPart,
	mouseOverInterface,
	options,
	terminalArgIndex,
	terminalText,
	terminalTextValid,
} from "../../store";
import { ArgumentSuggestion } from "../../types";
import { getMissingArgs, getValidPath } from "../../utils/command";
import { isQuoteEnded, isQuoteStarted } from "../../utils/string";
import { HistoryList } from "../history";
import { Frame } from "../ui/frame";
import { Padding } from "../ui/padding";
import { getArgumentSuggestion, getCommandSuggestion } from "./suggestion";
import { TerminalTextField } from "./terminal-text-field";

const MAX_HEIGHT = HISTORY_TEXT_SIZE * 10;
const TEXT_FIELD_HEIGHT = 40;
const TRAILING_SPACE_PATTERN = "(%s+)$";

interface TerminalProps {
	textParts: Derivable<string[]>;
}

export function Terminal({ textParts }: TerminalProps) {
	const client = useClient();
	const missingArgs = source<string[]>([]);
	const history = source<HistoryEntry[]>(client.dispatcher.getHistory());
	const historyHeight = source(0);

	useEvent(client.dispatcher.historyUpdated, (entries) =>
		history([...entries]),
	);

	const maxHeight = derive(() => px(MAX_HEIGHT));
	const scrollingEnabled = derive(() => historyHeight() > maxHeight());

	const atNextPart = derive(() => {
		const parts = read(textParts);
		const textPart = parts[parts.size() - 1] as string | undefined;

		let quoted = false;
		if (textPart !== undefined) {
			quoted = isQuoteStarted(textPart) && !isQuoteEnded(textPart);
		}
		return terminalText().sub(-1) === " " && !quoted;
	});

	const terminalHeight = derive(() => {
		const padding = px.ceil(TEXT_FIELD_HEIGHT + 16);
		if (history().isEmpty()) return padding;

		const clampedHeight = scrollingEnabled() ? maxHeight() : historyHeight();
		return math.ceil(padding + clampedHeight + px(8));
	});

	return (
		<Frame
			size={spring(() => new UDim2(1, 0, 0, terminalHeight()), 0.2)}
			backgroundColor={() => options().palette.background}
			backgroundTransparency={() => options().backgroundTransparency ?? 0}
			cornerRadius={() => new UDim(0, px(8))}
			native={{
				MouseEnter: () => mouseOverInterface(true),
				MouseLeave: () => mouseOverInterface(false),
			}}
		>
			<Padding all={() => new UDim(0, px(8))} />

			<HistoryList
				size={() => new UDim2(1, 0, 1, -px(TEXT_FIELD_HEIGHT + 8))}
				entries={history}
				scrollingEnabled={scrollingEnabled}
				onContentSizeChanged={(size) => {
					historyHeight(size.Y);
				}}
			/>

			<TerminalTextField
				anchor={new Vector2(0, 1)}
				size={() => new UDim2(1, 0, 0, px(TEXT_FIELD_HEIGHT))}
				position={UDim2.fromScale(0, 1)}
				backgroundTransparency={() => options().backgroundTransparency ?? 0}
				textParts={textParts}
				atNextPart={atNextPart}
				onTextChange={(text) => {
					terminalText(text);
					terminalTextValid(false);
					missingArgs([]);

					const parts = read(textParts);
					if (parts.isEmpty()) {
						currentCommandPath(undefined);
						currentSuggestion(undefined);
						terminalArgIndex(undefined);
						return;
					}

					// If the text ends in a space, we want to count that as having traversed
					// to the next "part" of the text.
					const endsInSpace = text.sub(-1) === " ";
					const path = getValidPath(client.registry, parts);
					const command =
						path !== undefined ? client.registry.getCommand(path) : undefined;

					if (command !== undefined) {
						// Check for missing arguments
						const missing = getMissingArgs(command, parts);
						const noArgs = command.options.arguments?.isEmpty() ?? true;

						currentCommandPath(path);
						missingArgs(missing);
						terminalTextValid(
							noArgs ||
								missing.isEmpty() ||
								(endsInSpace && missing.size() === 1),
						);
					} else {
						currentCommandPath(undefined);
						terminalTextValid(false);
					}

					let textPart = parts[parts.size() - 1] as string | undefined;
					const atNext = atNextPart();
					if (atNext) {
						textPart = undefined;
					} else if (textPart !== undefined) {
						const spaces = text.match(TRAILING_SPACE_PATTERN)[0] ?? "";
						textPart += spaces;
					}

					const argIndex =
						path !== undefined
							? parts.size() - path.size() - (atNext ? 0 : 1)
							: -1;
					terminalArgIndex(argIndex);

					if (command === undefined || argIndex === -1) {
						// Get command suggestions
						const parentPath = !parts.isEmpty()
							? new RegistryPath(
									ArrayUtil.slice(parts, 0, parts.size() - (atNext ? 0 : 1)),
								)
							: undefined;

						currentTextPart(textPart);
						currentSuggestion(
							getCommandSuggestion(
								client.registry,
								!parentPath?.isEmpty() ? parentPath : undefined,
								textPart,
							),
						);
						return;
					}

					// Handle arguments
					const args = command.options.arguments;
					if (args === undefined) {
						currentTextPart(undefined);
						currentSuggestion(undefined);
						commandArgIndex(undefined);
						return;
					}

					let index = -1;
					let currentArg: ReadonlyDeep<ArgumentOptions> | undefined;
					let endIndex: number | undefined = -1;
					for (const i of $range(0, argIndex)) {
						if (endIndex === undefined || i <= endIndex) continue;

						index++;
						if (index >= args.size()) {
							currentArg = undefined;
							break;
						}

						currentArg = args[index];
						const numArgs = currentArg.numArgs ?? 1;
						endIndex = numArgs !== "rest" ? i + (numArgs - 1) : undefined;
					}

					const argType = client.registry.getType(currentArg?.type ?? "");
					if (currentArg === undefined || argType === undefined) {
						currentSuggestion(undefined);
						currentTextPart(undefined);
						terminalArgIndex(undefined);
						commandArgIndex(undefined);
						return;
					}

					commandArgIndex(index);

					let argTextPart: string | undefined;
					let suggestion: ArgumentSuggestion;
					if (argType.kind === "single") {
						argTextPart = textPart;
						suggestion = getArgumentSuggestion(
							{
								kind: "single",
								options: currentArg,
								type: argType,
								input: argTextPart,
							},
							argTextPart,
						);
					} else {
						const textParts = splitString(textPart ?? "", ",", true);
						const lastText = !textParts.isEmpty()
							? textParts[textParts.size() - 1]
							: undefined;
						argTextPart = textPart?.sub(-1) !== "," ? lastText : undefined;
						suggestion = getArgumentSuggestion(
							{
								kind: "list",
								options: currentArg,
								type: argType,
								input: textParts.size() > 1 ? textParts : undefined,
							},
							argTextPart,
						);
					}

					currentTextPart(argTextPart);
					currentSuggestion(suggestion);
					if (suggestion?.error !== undefined) terminalTextValid(false);
				}}
				onSubmit={() => {
					const commandPath = currentCommandPath();
					const command =
						commandPath !== undefined
							? client.registry.getCommand(commandPath)
							: undefined;

					if (commandPath === undefined || command === undefined) {
						client.dispatcher.addHistoryEntry({
							success: false,
							text: "Command not found.",
							sentAt: os.time(),
						});
						return;
					}

					const missing = missingArgs();
					if (!missing.isEmpty()) {
						client.dispatcher.addHistoryEntry({
							success: false,
							text: `Missing arguments: ${missing.join(", ")}`,
							sentAt: os.time(),
						});
						return;
					}

					const args = ArrayUtil.slice(
						splitString(terminalText(), " "),
						commandPath.size(),
					);
					client.dispatcher.run(commandPath, args);
				}}
			/>
		</Frame>
	);
}


import Vide, {
	ActionAttributes,
	Derivable,
	InferEnumNames,
	InstanceAttributes,
} from "@rbxts/vide";

export interface FrameProps<T extends Instance = Frame>
	extends ActionAttributes<T> {
	size?: Derivable<UDim2>;
	position?: Derivable<UDim2>;
	anchor?: Derivable<Vector2>;
	rotation?: Derivable<number>;
	backgroundColor?: Derivable<Color3>;
	backgroundTransparency?: Derivable<number>;
	clipsDescendants?: Derivable<boolean>;
	visible?: Derivable<boolean>;
	zIndex?: Derivable<number>;
	layoutOrder?: Derivable<number>;
	cornerRadius?: Derivable<UDim>;
	automaticSize?: InferEnumNames<Enum.AutomaticSize>;
	native?: InstanceAttributes<T>;
}

export function Frame(props: FrameProps) {
	return (
		<frame
			AutomaticSize={props.automaticSize}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchor}
			Rotation={props.rotation}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			action={props.action}
			BorderSizePixel={0}
			{...props.native}
		>
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
			{props.children}
		</frame>
	);
}


import Vide, { Derivable } from "@rbxts/vide";
import { FrameProps } from "./frame";

interface GroupProps
	extends Omit<FrameProps, "backgroundTransparency" | "backgroundColor"> {
	name?: Derivable<string>;
	anchor?: Derivable<Vector2>;
	position?: Derivable<UDim2>;
	size?: Derivable<UDim2>;
	rotation?: Derivable<number>;
	clipsDescendants?: Derivable<boolean>;
	layoutOrder?: Derivable<number>;
	visible?: Derivable<boolean>;
	zIndex?: Derivable<number>;
}

export function Group(props: GroupProps) {
	return (
		<frame
			AutomaticSize={props.automaticSize}
			AnchorPoint={props.anchor}
			Position={props.position}
			Size={props.size ?? UDim2.fromScale(1, 1)}
			Rotation={props.rotation}
			BackgroundTransparency={1}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			action={props.action}
			BorderSizePixel={0}
			{...props.native}
		>
			{props.children}
		</frame>
	);
}


import Vide, { Derivable } from "@rbxts/vide";
import { IS_EDIT } from "../../constants/util";
import { Group } from "./group";

interface LayerProps extends Vide.PropsWithChildren {
	name?: Derivable<string>;
	visible?: Derivable<boolean>;
	displayOrder?: Derivable<number>;
}

export function Layer({ name, visible, displayOrder, children }: LayerProps) {
	return IS_EDIT ? (
		<Group name={name} visible={visible} zIndex={displayOrder}>
			{children}
		</Group>
	) : (
		<screengui
			Enabled={visible}
			ResetOnSpawn={false}
			DisplayOrder={displayOrder}
			IgnoreGuiInset
			ZIndexBehavior="Sibling"
			Name={name}
		>
			{children}
		</screengui>
	);
}


import Vide, { Derivable, derive, read } from "@rbxts/vide";
import { px } from "../../hooks/use-px";
import { Group } from "./group";

interface OutlineProps {
	outlineTransparency?: Derivable<number>;
	innerColor?: Derivable<Color3>;
	outerColor?: Derivable<Color3>;
	innerTransparency?: Derivable<number>;
	outerTransparency?: Derivable<number>;
	innerThickness?: Derivable<number>;
	outerThickness?: Derivable<number>;
	cornerRadius?: Derivable<UDim>;
	children?: Vide.Node;
}

function ceilEven(n: number) {
	return math.ceil(n / 2) * 2;
}

export function blend(...transparencies: number[]) {
	let result = 1;

	for (const transparency of transparencies) {
		result *= 1 - transparency;
	}

	return 1 - result;
}

const DEFAULT_INNER_COLOR = new Color3(1, 1, 1);
const DEFAULT_OUTER_COLOR = new Color3(0, 0, 0);

export function Outline({
	outlineTransparency = 0,
	innerColor = DEFAULT_INNER_COLOR,
	outerColor = DEFAULT_OUTER_COLOR,
	innerTransparency = 0.9,
	outerTransparency = 0.85,
	innerThickness,
	outerThickness,
	cornerRadius,
	children,
}: OutlineProps) {
	const properties = derive(() => ({
		innerThickness: innerThickness ?? px(3),
		outerThickness: outerThickness ?? px(1.5),
		cornerRadius: cornerRadius ?? new UDim(0, px(8)),
	}));

	const innerStyle = derive(() => {
		const outlineProps = properties();
		const thickness = read(outlineProps.innerThickness);
		const size = new UDim2(
			1,
			ceilEven(-2 * thickness),
			1,
			ceilEven(-2 * thickness),
		);

		const position = new UDim2(0, thickness, 0, thickness);
		const radius = read(outlineProps.cornerRadius).sub(new UDim(0, thickness));
		const transparency = math.clamp(
			blend(read(outlineTransparency), read(innerTransparency)),
			0,
			1,
		);

		return { size, position, radius, transparency };
	});

	const outerStyle = derive(() => {
		const transparency = math.clamp(
			blend(read(outlineTransparency), read(outerTransparency)),
			0,
			1,
		);

		return { transparency };
	});

	return (
		<>
			<Group
				size={() => innerStyle().size}
				position={() => innerStyle().position}
			>
				<uicorner CornerRadius={() => innerStyle().radius} />
				<uistroke
					Color={innerColor}
					Transparency={() => innerStyle().transparency}
					Thickness={innerThickness}
				>
					{children}
				</uistroke>
			</Group>

			<Group>
				<uicorner CornerRadius={cornerRadius} />
				<uistroke
					Color={outerColor}
					Transparency={() => outerStyle().transparency}
					Thickness={outerThickness}
				>
					{children}
				</uistroke>
			</Group>
		</>
	);
}


import Vide, { Derivable } from "@rbxts/vide";

interface Props {
	all?: Derivable<UDim>;
	left?: Derivable<UDim>;
	right?: Derivable<UDim>;
	top?: Derivable<UDim>;
	bottom?: Derivable<UDim>;
}

export function Padding({ all, left, right, top, bottom }: Props) {
	return (
		<uipadding
			PaddingLeft={left ?? all}
			PaddingRight={right ?? all}
			PaddingTop={top ?? all}
			PaddingBottom={bottom ?? all}
		/>
	);
}


import Vide, { Derivable, InferEnumNames } from "@rbxts/vide";
import { FrameProps } from "./frame";

export interface ScrollingFrameProps extends FrameProps<ScrollingFrame> {
	automaticSize?: InferEnumNames<Enum.AutomaticSize>;
	automaticCanvasSize?: InferEnumNames<Enum.AutomaticSize>;
	scrollingDirection?: InferEnumNames<Enum.ScrollingDirection>;
	scrollBarThickness?: Derivable<number>;
	scrollBarColor?: Derivable<Color3>;
	scrollBarTransparency?: Derivable<number>;
	scrollingEnabled?: Derivable<boolean>;
	canvasSize?: Derivable<UDim2>;
	canvasPosition?: Derivable<Vector2>;
}

export function ScrollingFrame(props: ScrollingFrameProps) {
	return (
		<scrollingframe
			AutomaticSize={props.automaticSize}
			AutomaticCanvasSize={props.automaticCanvasSize}
			ScrollingDirection={props.scrollingDirection}
			ScrollBarThickness={props.scrollBarThickness}
			ScrollBarImageColor3={props.scrollBarColor}
			ScrollBarImageTransparency={props.scrollBarTransparency}
			ScrollingEnabled={props.scrollingEnabled}
			CanvasSize={props.canvasSize}
			CanvasPosition={props.canvasPosition}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchor}
			Rotation={props.rotation}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency ?? 1}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			BorderSizePixel={0}
			action={props.action}
			{...props.native}
		>
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
			{props.children}
		</scrollingframe>
	);
}


import Vide, { Derivable, read } from "@rbxts/vide";
import { options } from "../../store";
import { TextProps } from "./text";

interface TextFieldProps extends TextProps<TextBox> {
	text?: Derivable<string>;
	placeholderText?: Derivable<string>;
	placeholderColor?: Derivable<Color3>;
	clearTextOnFocus?: Derivable<boolean>;
	multiLine?: Derivable<boolean>;
	textEditable?: Derivable<boolean>;
}

export function TextField(props: TextFieldProps) {
	return (
		<textbox
			PlaceholderText={props.placeholderText}
			PlaceholderColor3={props.placeholderColor}
			ClearTextOnFocus={props.clearTextOnFocus}
			MultiLine={props.multiLine}
			TextEditable={props.textEditable}
			Font={Enum.Font.Unknown}
			FontFace={() => {
				return read(props.font) ?? options().font.regular;
			}}
			Text={props.text}
			TextColor3={props.textColor}
			TextSize={props.textSize}
			TextTransparency={props.textTransparency}
			TextTruncate={props.textTruncate}
			TextWrapped={props.textWrapped}
			TextXAlignment={props.textXAlignment}
			TextYAlignment={props.textYAlignment}
			TextScaled={props.textScaled}
			RichText={props.richText}
			AutomaticSize={props.automaticSize}
			AutoLocalize={() => options().autoLocalize}
			Size={props.size}
			Position={props.position}
			AnchorPoint={props.anchor}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency ?? 1}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			BorderSizePixel={0}
			action={props.action}
			{...props.native}
		>
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
			{props.children}
		</textbox>
	);
}


import Vide, { Derivable, InferEnumNames, read } from "@rbxts/vide";
import { px } from "../../hooks/use-px";
import { options } from "../../store";
import { FrameProps } from "./frame";

export interface TextProps<T extends Instance = TextLabel>
	extends FrameProps<T> {
	font?: Derivable<Font>;
	text?: Derivable<string>;
	textColor?: Derivable<Color3>;
	textSize?: Derivable<number>;
	textTransparency?: Derivable<number>;
	textWrapped?: Derivable<boolean>;
	textXAlignment?: InferEnumNames<Enum.TextXAlignment>;
	textYAlignment?: InferEnumNames<Enum.TextYAlignment>;
	textTruncate?: InferEnumNames<Enum.TextTruncate>;
	textScaled?: Derivable<boolean>;
	textHeight?: Derivable<number>;
	richText?: Derivable<boolean>;
}

export function Text(props: TextProps) {
	return (
		<textlabel
			FontFace={() => read(props.font) ?? options().font.regular}
			Text={props.text}
			TextColor3={props.textColor}
			TextSize={() => read(props.textSize) ?? px(16)}
			TextTransparency={props.textTransparency}
			TextWrapped={props.textWrapped}
			TextXAlignment={props.textXAlignment}
			TextYAlignment={props.textYAlignment}
			TextTruncate={props.textTruncate}
			TextScaled={props.textScaled}
			LineHeight={props.textHeight}
			RichText={props.richText}
			Size={props.size}
			AutomaticSize={props.automaticSize}
			AutoLocalize={() => options().autoLocalize}
			Position={props.position}
			AnchorPoint={props.anchor}
			BackgroundColor3={props.backgroundColor}
			BackgroundTransparency={props.backgroundTransparency ?? 1}
			ClipsDescendants={props.clipsDescendants}
			Visible={props.visible}
			ZIndex={props.zIndex}
			LayoutOrder={props.layoutOrder}
			action={props.action}
			{...props.native}
		>
			{props.cornerRadius && <uicorner CornerRadius={props.cornerRadius} />}
			{props.children}
		</textlabel>
	);
}


import { GuiService } from "@rbxts/services";
import { DefaultPalette } from "../palette";
import { InterfaceOptions } from "../types";

export const DEFAULT_INTERFACE_OPTIONS: InterfaceOptions = {
	anchor: new Vector2(),
	position: (px) =>
		UDim2.fromOffset(px(16), px(8) + GuiService.GetGuiInset()[0].Y),
	size: (px) => new UDim2(0, px(1024), 1, 0),
	displayOrder: 1000,
	backgroundTransparency: 0.2,
	hideOnLostFocus: true,
	activationKeys: [Enum.KeyCode.F2],
	font: {
		regular: new Font("rbxassetid://16658221428"),
		medium: new Font("rbxassetid://16658221428", Enum.FontWeight.Medium),
		bold: new Font("rbxassetid://16658221428", Enum.FontWeight.Bold),
	},
	palette: DefaultPalette.mocha,
	autoLocalize: false,
};


import { RunService } from "@rbxts/services";

export const IS_EDIT = RunService.IsStudio() && !RunService.IsRunning();


import { CenturionClient } from "@rbxts/centurion";
import { source } from "@rbxts/vide";

const clientState = source<CenturionClient>();

export function useClient(client?: CenturionClient) {
	const value = clientState();
	if (value !== undefined) return value;

	assert(client !== undefined, "No client has been set.");
	clientState(client);
	return client;
}


import { cleanup } from "@rbxts/vide";

type EventLike<T extends Callback = Callback> =
	| { Connect(callback: T): ConnectionLike }
	| { connect(callback: T): ConnectionLike }
	| { subscribe(callback: T): ConnectionLike };

type ConnectionLike =
	| { Disconnect(): void }
	| { disconnect(): void }
	| (() => void);

const connect = (event: EventLike, callback: Callback): ConnectionLike => {
	if (typeIs(event, "RBXScriptSignal")) {
		// With deferred events, a "hard disconnect" is necessary to avoid causing
		// state updates after a component unmounts. Use 'Connected' to check if
		// the connection is still valid before invoking the callback.
		// https://devforum.roblox.com/t/deferred-engine-events/2276564/99
		const connection = event.Connect((...args: unknown[]) => {
			if (connection.Connected) {
				return callback(...args);
			}
		});
		return connection;
	}

	if ("Connect" in event) {
		return event.Connect(callback);
	}

	if ("connect" in event) {
		return event.connect(callback);
	}

	if ("subscribe" in event) {
		return event.subscribe(callback);
	}

	throw "Event-like object does not have a supported connect method.";
};

const disconnect = (connection: ConnectionLike) => {
	if (typeIs(connection, "function")) {
		connection();
	} else if (
		typeIs(connection, "RBXScriptConnection") ||
		"Disconnect" in connection
	) {
		connection.Disconnect();
	} else if ("disconnect" in connection) {
		connection.disconnect();
	} else {
		throw "Connection-like object does not have a supported disconnect method.";
	}
};

export function useEvent<T extends EventLike>(
	event: T,
	listener: T extends EventLike<infer U> ? U : never,
) {
	const connection = connect(event, listener);
	cleanup(() => {
		disconnect(connection);
	});
}


import { Workspace } from "@rbxts/services";
import { cleanup, source } from "@rbxts/vide";

const BASE_RESOLUTION = new Vector2(1280, 832);
const MIN_SCALE = 0.5;
const DOMINANT_AXIS = 0.5;

const scale = source(1);

/**
 * Assigns a call signature to an object.
 *
 * @param callback The function to assign.
 * @param object The object to assign the function to.
 * @returns A callable object.
 */
function callable<T extends Callback, U>(callback: T, object: U): T & U {
	return setmetatable(object as never, {
		__call: (_, ...args) => callback(...args),
	});
}

/**
 * Rounds and scales a number to the current `px` unit. Includes additional
 * methods for edge cases.
 *
 * @param value The number to scale.
 * @returns A number in scaled `px` units.
 */
export const px = callable((value: number) => math.round(value * scale()), {
	even: (value: number) => math.round(value * scale() * 0.5) * 2,
	scale: (value: number) => value * scale(),
	floor: (value: number) => math.floor(value * scale()),
	ceil: (value: number) => math.ceil(value * scale()),
});

export type ScaleFunction = typeof px;

/**
 * Scales the current `px` unit based on the current viewport size. Should be
 * called once when mounting the app.
 */
export function usePx() {
	const camera = Workspace.CurrentCamera;
	assert(camera !== undefined, "CurrentCamera is not set");

	const updateScale = () => {
		const width = math.log(camera.ViewportSize.X / BASE_RESOLUTION.X, 2);
		const height = math.log(camera.ViewportSize.Y / BASE_RESOLUTION.Y, 2);
		const centered = width + (height - width) * DOMINANT_AXIS;

		scale(math.max(2 ** centered, MIN_SCALE));
	};

	const connection = camera
		.GetPropertyChangedSignal("ViewportSize")
		.Connect(() => {
			updateScale();
		});

	cleanup(connection);

	updateScale();
}


import { source } from "@rbxts/vide";

const ref = source<TextBox>();

export function updateText(text: string) {
	const textBox = ref();
	if (textBox === undefined) return;
	textBox.Text = text;
	textBox.CursorPosition = text.size() + 1;
}

export function useTextBox(textBox: TextBox) {
	ref(textBox);
}


import { ImmutableRegistryPath } from "@rbxts/centurion";
import { source } from "@rbxts/vide";
import { DEFAULT_INTERFACE_OPTIONS } from "./constants/options";
import { Suggestion } from "./types";

export const visible = source(false);
export const options = source(DEFAULT_INTERFACE_OPTIONS);
export const mouseOverInterface = source(false);

export const currentCommandPath = source<ImmutableRegistryPath | undefined>(
	undefined,
);
export const commandArgIndex = source<number | undefined>(undefined);
export const terminalArgIndex = source<number | undefined>(undefined);

export const currentSuggestion = source<Suggestion | undefined>(undefined);
export const terminalText = source("");
export const terminalTextValid = source(false);
export const currentTextPart = source<string | undefined>(undefined);


import { CommandShortcut, HistoryEntry } from "@rbxts/centurion";
import { ScaleFunction } from "./hooks/use-px";
import { InterfacePalette } from "./palette";

export interface InterfaceOptions {
	anchor: Vector2;
	position: UDim2 | ((px: ScaleFunction) => UDim2);
	size: UDim2 | ((px: ScaleFunction) => UDim2);
	displayOrder: number;
	backgroundTransparency: number;
	hideOnLostFocus: boolean;
	activationKeys: Enum.KeyCode[];
	font: {
		regular: Font;
		medium: Font;
		bold: Font;
	};
	palette: InterfacePalette;
	autoLocalize: boolean;
}

export interface HistoryLineData {
	entry: HistoryEntry;
	height: number;
}

export interface HistoryData {
	lines: HistoryLineData[];
	height: number;
}

export interface CommandSuggestion {
	type: "command";
	title: string;
	others: string[];
	description?: string;
	shortcuts?: CommandShortcut[];
}

export interface ArgumentSuggestion {
	type: "argument";
	title: string;
	others: string[];
	description?: string;
	dataType: string;
	optional: boolean;
	error?: string;
}

export type Suggestion = CommandSuggestion | ArgumentSuggestion;


import {
	BaseRegistry,
	ImmutableRegistryPath,
	RegistryPath,
} from "@rbxts/centurion";
import { BaseCommand } from "@rbxts/centurion/out/shared/core/command";
import { IS_EDIT } from "../constants/util";

export function formatPartsAsPath(parts: string[]) {
	return parts.join("/").lower();
}

export function getValidPath(registry: BaseRegistry, text: string[]) {
	if (text.size() === 0) return;

	const textPath = formatPartsAsPath(text);
	if (
		registry.getCommandByString(textPath) !== undefined ||
		registry.getGroupByString(textPath) !== undefined
	) {
		return ImmutableRegistryPath.fromString(textPath);
	}

	const path = RegistryPath.empty();
	for (const part of text) {
		path.append(part.lower());

		if (
			registry.getCommand(path) === undefined &&
			registry.getGroup(path) === undefined &&
			registry.getChildPaths(path).isEmpty()
		) {
			path.remove(path.size() - 1);
			break;
		}
	}

	if (
		registry.getCommand(path) !== undefined ||
		registry.getGroup(path) !== undefined
	) {
		return ImmutableRegistryPath.fromPath(path);
	}
}

export function getArgumentNames(registry: BaseRegistry, path: RegistryPath) {
	if (IS_EDIT) return [];

	const command = registry.getCommand(path);
	if (command === undefined || command.options.arguments === undefined) {
		return [];
	}

	return command.options.arguments.map((arg) => arg.name);
}

export function getMissingArgs(command: BaseCommand, text: string[]) {
	if (
		command.options.arguments === undefined ||
		command.options.arguments.isEmpty()
	) {
		return [];
	}

	const lastPartIndex = text.size() - command.getPath().size() - 1;
	const missingArgs: string[] = [];

	let index = 0;
	for (const arg of command.options.arguments) {
		if (arg.optional) break;
		if (index > lastPartIndex) {
			missingArgs.push(arg.name);
		}
		index++;
	}

	return missingArgs;
}


import { BaseRegistry } from "@rbxts/centurion";
import {
	commandArgIndex,
	currentCommandPath,
	currentSuggestion,
	currentTextPart,
	terminalArgIndex,
} from "../store";
import { Suggestion } from "../types";
import { formatPartsAsPath, getArgumentNames } from "./command";

export function getAutocompletedText(
	text: string,
	textParts: string[],
	registry: BaseRegistry,
) {
	// Handle autocompletion
	const commandPath = currentCommandPath();
	const suggestion = currentSuggestion();
	if (suggestion === undefined) return;

	if (commandPath === undefined) {
		return completeCommand(registry, text, textParts, suggestion.title);
	}

	const argCount =
		registry.getCommand(commandPath)?.options.arguments?.size() ?? 0;
	if (suggestion.type === "command" && argCount !== 0) {
		return `${text} `;
	}

	if (suggestion.others.isEmpty()) return text;
	return completeArgument(text, suggestion.others[0], argCount);
}

function replaceTextPart(text: string, ...suggestions: string[]) {
	const currentPart = currentTextPart();
	const currentPartSize = currentPart?.size() ?? 0;

	let result = text.sub(0, text.size() - currentPartSize);
	result += suggestions.join(" ");
	return result;
}

function completeCommand(
	registry: BaseRegistry,
	text: string,
	textParts: string[],
	suggestion: string,
) {
	const atNextPart = text.sub(-1) === " ";
	const pathParts = [...textParts];
	if (!atNextPart) {
		pathParts.remove(textParts.size() - 1);
	}
	pathParts.push(suggestion);

	const command = registry.getCommandByString(
		formatPartsAsPath(pathParts),
	)?.options;

	let result = replaceTextPart(text, suggestion);

	// Add a space if this path isn't a command (meaning it's a group) or is a command with arguments
	if (command === undefined || !(command.arguments?.isEmpty() ?? true)) {
		result += " ";
	}

	return result;
}

function completeArgument(
	text: string,
	suggestion: string,
	commandArgCount: number,
) {
	const argIndex = terminalArgIndex();
	if (argIndex === undefined || commandArgCount === 0) return text;

	let result = replaceTextPart(text, suggestion);
	if (argIndex < commandArgCount - 1) {
		result += " ";
	}
	return result;
}

export function getSuggestedText(
	registry: BaseRegistry,
	text: string,
	textParts: string[],
	atNextPart: boolean,
	suggestion?: Suggestion,
) {
	if (suggestion === undefined) return "";

	let suggestionText: string;

	if (textParts.isEmpty() || suggestion.type === "command") {
		suggestionText = suggestion.title;
	} else {
		const command = currentCommandPath();
		const argIndex = terminalArgIndex();

		if (command === undefined || argIndex === undefined) return "";

		if (atNextPart && argIndex === commandArgIndex()) {
			suggestionText = suggestion.title;
		} else if (!suggestion.others.isEmpty()) {
			suggestionText = suggestion.others[0];
		} else {
			suggestionText = currentTextPart() ?? "";
		}

		const argNames = getArgumentNames(registry, command);

		for (const i of $range(argIndex + 1, argNames.size() - 1)) {
			suggestionText += ` ${argNames[i]}`;
		}
	}

	const partSize = currentTextPart()?.size() ?? 0;

	let result = text;
	result += suggestionText.sub(partSize + 1);
	return result;
}


import { Centurion, CenturionLogLevel } from "@rbxts/centurion";
import { CenturionUI } from "@rbxts/centurion-ui";

const client = Centurion.client({
	logLevel: CenturionLogLevel.Debug,
});

client.registry.synced.Connect((synced, incoming) => {
	print("Synced:", synced, "Incoming:", incoming);
});

client
	.start()
	.then(() => CenturionUI.start(client))
	.catch((err) => warn("Failed to start Centurion:", err));


import {
	Centurion,
	CenturionType,
	Command,
	CommandContext,
	Group,
	Register,
} from "@rbxts/centurion";
import { addAdmin, removeAdmin } from "../permissions";

@Register({
	groups: [
		{
			name: "admin",
			description: "Commands for managing admins",
		},
	],
})
@Group("admin")
export class AdminCommand {
	@Command({
		name: "add",
		description: "Add an admin",
		arguments: [
			{
				name: "player",
				description: "The player to add",
				type: CenturionType.Player,
			},
		],
	})
	add(ctx: CommandContext, player: Player) {
		addAdmin(player);
		Centurion.server().registry.sync(player);
		ctx.reply(`Gave admin permissions to ${player.Name}`);
	}

	@Command({
		name: "remove",
		description: "Remove an admin",
		arguments: [
			{
				name: "player",
				description: "The player to remove",
				type: CenturionType.Player,
			},
		],
	})
	remove(ctx: CommandContext, player: Player) {
		removeAdmin(player);
		Centurion.server().registry.sync(player);
		ctx.reply(`Removed admin permissions from ${player.Name}`);
	}
}


import {
	CenturionType,
	Command,
	CommandContext,
	Register,
} from "@rbxts/centurion";

@Register()
export class DamageCommand {
	@Command({
		name: "damage",
		description: "Damages a player",
		arguments: [
			{
				name: "player",
				description: "Player to damage",
				type: CenturionType.Player,
			},
			{
				name: "damage",
				description: "Amount to damage player",
				type: CenturionType.Number,
			},
		],
		aliases: ["dmg"],
	})
	damage(ctx: CommandContext, player: Player, damage: number) {
		if (player.Character === undefined) {
			ctx.error("Player's character does not exist");
			return;
		}

		const humanoid = player.Character.FindFirstChildOfClass("Humanoid");
		if (humanoid === undefined) {
			ctx.error("Player's humanoid does not exist");
			return;
		}

		if (humanoid.Health === 0) {
			ctx.error("Player is already dead");
			return;
		}

		humanoid.Health -= damage;
		ctx.reply(`Damaged <b>${player.DisplayName}</b> for <b>${damage}</b> HP`);
	}
}


import {
	CenturionType,
	Command,
	CommandContext,
	Register,
} from "@rbxts/centurion";

@Register()
export class DurationCommand {
	@Command({
		name: "duration",
		description: "Prints the duration in seconds",
		arguments: [
			{
				name: "duration",
				description: "The duration to print",
				type: CenturionType.Duration,
			},
		],
	})
	duration(ctx: CommandContext, duration: number) {
		ctx.reply(`${duration} second(s)`);
	}
}


import {
	CenturionType,
	Command,
	CommandContext,
	Register,
} from "@rbxts/centurion";

@Register()
export class EchoCommand {
	@Command({
		name: "echo",
		description: "Prints the given text",
		arguments: [
			{
				name: "text",
				description: "The text to print",
				type: CenturionType.String,
				suggestions: ["Hello, World!", "Some other spaced text", "Test"],
			},
		],
		aliases: ["print"],
	})
	run(_: CommandContext, text: string) {
		print(text);
	}
}


import {
	CenturionType,
	Command,
	CommandContext,
	Group,
	Register,
} from "@rbxts/centurion";

@Register({
	groups: [
		{
			name: "info",
			description: "View info about a user or the server",
		},
		{
			name: "user",
			description: "View info about a user",
			parent: ["info"],
		},
		{
			name: "server",
			description: "View info about the server",
			parent: ["info"],
		},
	],
})
@Group("info")
export class InfoCommand {
	@Command({
		name: "view",
		description: "Views information about a user",
		arguments: [
			{
				name: "player",
				description: "Player to view information for",
				type: CenturionType.Player,
			},
		],
	})
	@Group("user")
	userView(ctx: CommandContext, player: Player) {
		ctx.reply(`(Random data about ${player.Name} here)`);
	}

	@Command({
		name: "view",
		description: "Views information about the server",
	})
	@Group("server")
	serverView(ctx: CommandContext) {
		ctx.reply("(Random data about the server here)");
	}
}


import {
	CenturionType,
	Command,
	CommandContext,
	Register,
} from "@rbxts/centurion";

@Register()
export class KickCommand {
	@Command({
		name: "kick",
		description: "Kicks a player",
		arguments: [
			{
				name: "player",
				description: "Player to kick",
				type: CenturionType.Player,
			},
			{
				name: "reason",
				description: "Reason for kicking the player",
				type: CenturionType.String,
				numArgs: "rest",
			},
		],
	})
	kick(_: CommandContext, player: Player, reason: string[]) {
		const reasonText = reason.join(" ");
		print(`Kicked ${player.Name} for ${reasonText}`);
	}
}


import {
	CenturionType,
	Command,
	CommandContext,
	Register,
} from "@rbxts/centurion";
import { Players } from "@rbxts/services";

@Register()
export class KillCommand {
	@Command({
		name: "kill",
		description: "Kill players",
		arguments: [
			{
				name: "players",
				description: "Players to kill",
				type: CenturionType.Players,
				optional: true,
			},
		],
		shortcuts: [[Enum.KeyCode.F4]],
	})
	kill(ctx: CommandContext, players?: Player[]) {
		const playersArray = players ?? Players.GetPlayers();

		for (const player of playersArray) {
			this.killPlayer(player);
		}

		ctx.reply(`Successfully killed ${playersArray.size()} player(s)`);
	}

	private killPlayer(player: Player) {
		const humanoid = player.Character?.FindFirstChildOfClass("Humanoid");
		if (humanoid === undefined) {
			return;
		}

		humanoid.Health = 0;
	}
}


import { Centurion, CenturionLogLevel } from "@rbxts/centurion";
import { Players } from "@rbxts/services";
import { addAdmin, isAdmin, removeAdmin } from "./permissions";

let firstPlayer = true;
Players.PlayerAdded.Connect((player) => {
	// Give the first player admin permissions
	if (firstPlayer) {
		addAdmin(player);
		firstPlayer = false;
	}
});

Players.PlayerRemoving.Connect((player) => {
	removeAdmin(player);
});

const server = Centurion.server({
	logLevel: CenturionLogLevel.Debug,
	syncFilter: (player) => {
		if (firstPlayer) {
			firstPlayer = false;
			addAdmin(player);
		}
		return isAdmin(player);
	},
});

assert(script.Parent !== undefined);
server.registry.load(script.Parent.WaitForChild("commands"));
server.start();


declare const _G: Map<unknown, unknown>;

_G.set("NOCOLOR", true);

import { runCLI } from "@rbxts/jest";
import { ReplicatedStorage } from "@rbxts/services";

function getTests(root: Instance) {
	const folder = root.WaitForChild("TS").WaitForChild("__tests__");
	assert(
		classIs(folder, "Folder"),
		"Could not find __tests__ folder in ReplicatedStorage.TS",
	);
	return folder;
}

const setup = script.Parent?.WaitForChild("setup");
assert(
	setup !== undefined && classIs(setup, "ModuleScript"),
	"Could not find setup module script",
);

const [status, result] = runCLI(
	script,
	{
		verbose: false,
		ci: false,
		setupFiles: [setup],
	},
	[getTests(ReplicatedStorage)],
).awaitStatus();

if (status === "Rejected") {
	print(result);
}


import { useCallback, useRef, useState } from "@rbxts/react";
import { useUnmountEffect } from "../use-unmount-effect";

export type AsyncState<T> =
	| {
			status: PromiseConstructor["Status"]["Started"];
			message?: undefined;
			value?: undefined;
	  }
	| {
			status: PromiseConstructor["Status"]["Resolved"];
			message?: undefined;
			value: T;
	  }
	| {
			status: PromiseConstructor["Status"]["Cancelled"] | PromiseConstructor["Status"]["Rejected"];
			message: unknown;
			value?: undefined;
	  };

type AnyAsyncState<T> = {
	status: Promise.Status;
	message?: unknown;
	value?: T;
};

export type AsyncCallback<T, U extends unknown[]> = (...args: U) => Promise<T>;

/**
 * Returns a tuple containing the current state of the promise and a callback
 * to start a new promise. Calling it will cancel any previous promise.
 * @param callback The async callback.
 * @returns The state and a new callback.
 */
export function useAsyncCallback<T, U extends unknown[]>(
	callback: AsyncCallback<T, U>,
): LuaTuple<[AsyncState<T>, AsyncCallback<T, U>]> {
	const currentPromise = useRef<Promise<T>>();

	const [state, setState] = useState<AnyAsyncState<T>>({
		status: Promise.Status.Started,
	});

	const execute = useCallback(
		(...args: U) => {
			currentPromise.current?.cancel();

			if (state.status !== Promise.Status.Started) {
				setState({ status: Promise.Status.Started });
			}

			const promise = callback(...args);

			promise.then(
				(value) => setState({ status: promise.getStatus(), value }),
				(message: unknown) => setState({ status: promise.getStatus(), message }),
			);

			return (currentPromise.current = promise);
		},
		[callback],
	);

	useUnmountEffect(() => {
		currentPromise.current?.cancel();
	});

	return $tuple(state as AsyncState<T>, execute);
}


import { useEffect } from "@rbxts/react";

/**
 * Runs an async effect and cancels the promise when unmounting the effect.
 * Note that effects paused by `await` still run while cancelled, so prefer
 * to use promise chaining instead.
 * @param effect The async effect to run.
 * @param deps The dependencies to run the effect on.
 */
export function useAsyncEffect(effect: () => Promise<any>, deps?: unknown[]) {
	useEffect(() => {
		const promise = effect();

		return () => {
			promise.cancel();
		};
	}, deps);
}


import { useEffect } from "@rbxts/react";
import { AsyncState, useAsyncCallback } from "../use-async-callback";

type AsyncStateTuple<T extends AsyncState<unknown>> = LuaTuple<
	[result: T["value"], status: T["status"], message: T["message"]]
>;

/**
 * Returns a tuple containing the result and status of a promise. When the
 * dependencies change, pending promises will be cancelled, and a new promise
 * will be started.
 * @param callback The async callback.
 * @param deps The dependencies to watch. Defaults to an empty array.
 * @returns The result and status of the promise.
 */
export function useAsync<T>(callback: () => Promise<T>, deps: unknown[] = []): AsyncStateTuple<AsyncState<T>> {
	const [state, asyncCallback] = useAsyncCallback(callback);

	useEffect(() => {
		asyncCallback();
	}, deps);

	return $tuple(state.value, state.status, state.message);
}


import { Binding, useEffect, useMemo } from "@rbxts/react";
import { useLatestCallback } from "../use-latest-callback";
import { getBindingApi, isBinding } from "../utils/binding";

/**
 * Subscribes to a binding and calls the given listener when the binding
 * updates. If the value passed is not a binding, the listener will be called
 * with the value.
 *
 * The `listener` function is safe to not be memoized, as it will only be
 * called when the binding updates.
 *
 * @param binding The binding to subscribe to.
 * @param listener The function to call when the binding updates.
 */
export function useBindingListener<T>(binding: T | Binding<T>, listener: (value: T) => void) {
	const api = useMemo(() => {
		return isBinding<T>(binding) ? getBindingApi(binding) : undefined;
	}, [binding]);

	const listenerCallback = useLatestCallback(listener);

	useEffect(() => {
		if (api) {
			listenerCallback(api.getValue());
			return api.subscribe(listenerCallback);
		} else {
			listenerCallback(binding as T);
		}
	}, [binding]);
}


import { Binding, useState } from "@rbxts/react";
import { useBindingListener } from "../use-binding-listener";
import { getBindingValue } from "../utils/binding";

/**
 * Returns the value of a binding. If the binding updates, the component will
 * be re-rendered. Non-binding values will be returned as-is.
 * @param binding The binding to get the value of.
 * @returns The value of the binding.
 */
export function useBindingState<T>(binding: T | Binding<T>): T {
	const [value, setValue] = useState(() => getBindingValue(binding));
	useBindingListener(binding, setValue);
	return value;
}


import { useState } from "@rbxts/react";
import { Workspace } from "@rbxts/services";
import { useEventListener } from "../use-event-listener";

/**
 * Returns the current camera. Updates when the current camera changes.
 * @returns A camera instance.
 */
export function useCamera() {
	const [camera, setCamera] = useState(Workspace.CurrentCamera!);

	useEventListener(Workspace.GetPropertyChangedSignal("CurrentCamera"), () => {
		if (Workspace.CurrentCamera) {
			setCamera(Workspace.CurrentCamera);
		}
	});

	return camera;
}


import { useMemo } from "@rbxts/react";
import { useLatestCallback } from "../use-latest-callback";

export type RefFunction<T> = (rbx?: T) => void;

/**
 * Composes multiple ref functions into a single ref function and memoizes
 * the result.
 *
 * To prevent excess ref calls, the composed ref is only created once on mount.
 * However, it will call the latest refs passed, so it is safe to pass in refs
 * that might change.
 *
 * @param refs The ref functions to compose.
 * @returns A ref function that calls all of the ref functions passed in.
 */
export function useComposedRef<T>(...refs: (RefFunction<T> | undefined)[]): RefFunction<T> {
	const composedRef = useMemo(() => {
		return composeRefs(...refs);
	}, refs);

	// Make sure the function returned never changes when dependencies change.
	// Otherwise, the ref will be called again, and might cause other problems.
	return useLatestCallback(composedRef);
}

/**
 * Composes multiple ref functions into a single ref function.
 * @param refs The ref functions to compose.
 * @returns A ref function that calls all of the ref functions passed in.
 */
export function composeRefs<T>(...refs: (RefFunction<T> | undefined)[]): RefFunction<T> {
	const refsDefined = refs.filterUndefined();

	return (rbx) => {
		for (const ref of refsDefined) {
			ref(rbx);
		}
	};
}


import { useMemo } from "@rbxts/react";
import { DebounceOptions, Debounced, debounce } from "@rbxts/set-timeout";
import { useLatest } from "../use-latest";
import { useUnmountEffect } from "../use-unmount-effect";

export interface UseDebounceOptions extends DebounceOptions {
	/**
	 * The amount of time to wait before the first call.
	 */
	wait?: number;
}

export interface UseDebounceResult<T extends Callback> {
	/**
	 * The debounced function.
	 */
	run: Debounced<T>;
	/**
	 * Cancels delayed invocations to the callback.
	 */
	cancel: () => void;
	/**
	 * Immediately invokes delayed callback invocations.
	 */
	flush: () => void;
	/**
	 * Returns whether any invocations are pending.
	 */
	pending: () => boolean;
}

/**
 * Creates a debounced function that delays invoking `callback` until after `wait`
 * seconds have elapsed since the last time the debounced function was invoked.
 * The `callback` is invoked with the last arguments provided to the debounced
 * function. Subsequent calls to the debounced function return the result of
 * the last `callback` invocation.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `debounce` and `throttle`.
 *
 * @param callback The function to debounce.
 * @param options The options object.
 * @returns The new debounced function.
 */
export function useDebounceCallback<T extends Callback>(
	callback: T,
	options: UseDebounceOptions = {},
): UseDebounceResult<T> {
	const callbackRef = useLatest(callback);

	const debounced = useMemo(() => {
		return debounce(
			(...args: unknown[]) => {
				return callbackRef.current(...args);
			},
			options.wait,
			options,
		);
	}, []) as Debounced<T>;

	useUnmountEffect(() => {
		debounced.cancel();
	});

	return {
		run: debounced,
		cancel: debounced.cancel,
		flush: debounced.flush,
		pending: debounced.pending,
	};
}


import { useEffect } from "@rbxts/react";
import { UseDebounceOptions, useDebounceCallback } from "../use-debounce-callback";
import { useUpdate } from "../use-update";
import { useUpdateEffect } from "../use-update-effect";

/**
 * Creates a debounced effect that delays invoking `effect` until after `wait`
 * seconds have elapsed since the last time the debounced function was invoked.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `debounce` and `throttle`.
 *
 * @param effect The effect to debounce.
 * @param dependencies The dependencies array.
 * @param options The options object.
 */
export function useDebounceEffect(
	effect: () => (() => void) | void,
	dependencies?: unknown[],
	options?: UseDebounceOptions,
) {
	const update = useUpdate();

	const { run } = useDebounceCallback(update, options);

	useEffect(() => {
		return run();
	}, dependencies);

	useUpdateEffect(effect, [update]);
}


import { Dispatch, SetStateAction, useState } from "@rbxts/react";
import { UseDebounceOptions, useDebounceCallback } from "../use-debounce-callback";

/**
 * Delays updating `state` until after `wait` seconds have elapsed since the
 * last time the debounced function was invoked. Set to the most recently passed
 * `state` after the delay.
 *
 * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
 * for details over the differences between `debounce` and `throttle`.
 *
 * @param initialState The value to debounce.
 * @param options The options object.
 * @returns A tuple containing the debounced value and a function to update it.
 */
export function useDebounceState<T>(
	initialState: T,
	options?: UseDebounceOptions,
): LuaTuple<[T, Dispatch<SetStateAction<T>>]> {
	const [state, setState] = useState(initialState);

	return $tuple(state, useDebounceCallback(setState, options).run);
}


import { useCallback, useRef } from "@rbxts/react";
import { RunService } from "@rbxts/services";
import { useLatest } from "../use-latest";

/**
 * Defers a callback to be executed on the next Heartbeat frame. Consecutive
 * calls to the returned `execute` function will cancel the previous call.
 * @param callback The callback to defer
 * @returns A tuple containing the `execute` and `cancel` functions
 */
export function useDeferCallback<T extends unknown[]>(
	callback: (...args: T) => void,
): LuaTuple<[execute: (...args: T) => void, cancel: () => void]> {
	const callbackRef = useLatest(callback);
	const connectionRef = useRef<RBXScriptConnection>();

	const cancel = useCallback(() => {
		connectionRef.current?.Disconnect();
		connectionRef.current = undefined;
	}, []);

	const execute = useCallback((...args: T) => {
		cancel();

		connectionRef.current = RunService.Heartbeat.Once(() => {
			connectionRef.current = undefined;
			callbackRef.current(...args);
		});
	}, []);

	return $tuple(execute, cancel);
}


import { useEffect } from "@rbxts/react";
import { useDeferCallback } from "../use-defer-callback";

/**
 * Like `useEffect`, but the callback is deferred to the next Heartbeat frame.
 * @param callback The callback to run
 * @param dependencies Optional dependencies to trigger the effect
 */
export function useDeferEffect(callback: () => void, dependencies?: unknown[]) {
	const [deferredCallback, cancel] = useDeferCallback(callback);

	useEffect(() => {
		deferredCallback();
		return cancel;
	}, dependencies);
}