Skip to content

Commit

Permalink
feat: implement list types 🎉
Browse files Browse the repository at this point in the history
Closes #13

- Implements list types, allowing types to accept a list of comma-separated inputs when transforming an argument or providing suggestions.

- Fixed a bug in the `players` type allowing more than one of the same player being added to the array.

- Fixed UI suggestion bugs

- Refactored some UI suggestion code
  • Loading branch information
paradoxuum committed Aug 3, 2024
1 parent a36b9a5 commit 69f5d5e
Show file tree
Hide file tree
Showing 9 changed files with 350 additions and 202 deletions.
24 changes: 13 additions & 11 deletions packages/core/src/shared/builtin/types/players.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Players } from "@rbxts/services";
import { t } from "@rbxts/t";
import { CenturionType } from ".";
import { BaseRegistry } from "../../core/registry";
import { TransformResult, TypeBuilder } from "../../util/type";
import { ListTypeBuilder, TransformResult, TypeBuilder } from "../../util/type";

const getPlayer = (
text: string,
Expand Down Expand Up @@ -41,30 +41,32 @@ const playerType = TypeBuilder.create<Player>(CenturionType.Player)
.suggestions(getPlayerSuggestions)
.build();

const PlayersType = TypeBuilder.create<Player[]>(CenturionType.Players)
const playersType = ListTypeBuilder.create<Player[]>(CenturionType.Players)
.validate(t.array(isPlayer))
.transform((text, executor) => {
.transform((input, executor) => {
const includedPlayers = new Set<Player>();
let players: Player[] = [];
for (const [part] of text.gmatch("[@_%w%.%*]+")) {
const textPart = part as string;

if (textPart === "@all" || textPart === "*") {
for (const text of input) {
if (text === "@all" || text === "*") {
players = Players.GetPlayers();
break;
}

if (textPart === "@others" || textPart === "**") {
if (text === "@others" || text === "**") {
players = Players.GetPlayers().filter((player) => player !== executor);
break;
}

const playerResult = getPlayer(textPart, executor);
const playerResult = getPlayer(text, executor);
if (!playerResult.ok) {
return TransformResult.err(`Player not found: ${textPart}`);
return TransformResult.err(`Player not found: ${text}`);
}

if (includedPlayers.has(playerResult.value)) continue;
includedPlayers.add(playerResult.value);
players.push(playerResult.value);
}

return TransformResult.ok(players);
})
.suggestions(() => {
Expand All @@ -75,5 +77,5 @@ const PlayersType = TypeBuilder.create<Player[]>(CenturionType.Players)
.build();

export = (registry: BaseRegistry) => {
registry.registerType(playerType, PlayersType);
registry.registerType(playerType, playersType);
};
20 changes: 12 additions & 8 deletions packages/core/src/shared/core/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ReadonlyDeepObject,
} from "../util/data";
import { CenturionLogger } from "../util/log";
import { splitString } from "../util/string";
import { TransformResult } from "../util/type";
import { CommandContext } from "./context";
import { ImmutableRegistryPath } from "./path";
Expand Down Expand Up @@ -169,15 +170,18 @@ export class ExecutableCommand extends BaseCommand {

const argValues: unknown[] = [];
for (const i of $range(0, argInputs.size() - 1)) {
const transformedArg = arg.type.transform(
argInputs[i],
context.executor,
);

if (!transformedArg.ok) {
return TransformResult.err(transformedArg.value);
let result: TransformResult.Object<unknown>;
if (arg.type.kind === "single") {
result = arg.type.transform(argInputs[i], context.executor);
} else {
result = arg.type.transform(
splitString(argInputs[i], ","),
context.executor,
);
}
argValues[i] = transformedArg.value;

if (!result.ok) return TransformResult.err(result.value);
argValues[i] = result.value;
}

transformedArgs[argIndex] =
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,26 @@ export interface RegisterOptions {
groups?: GroupOptions[];
}

export interface ArgumentType<T> {
export interface SingleArgumentType<T> {
kind: "single";
name: string;
expensive: boolean;
validate: t.check<T>;
transform: (text: string, executor?: Player) => TransformResult.Object<T>;
suggestions?: (text: string, executor?: Player) => string[];
}

export interface ListArgumentType<T> {
kind: "list";
name: string;
expensive: boolean;
validate: t.check<T>;
transform: (input: string[], executor?: Player) => TransformResult.Object<T>;
suggestions?: (input: string[], executor?: Player) => string[];
}

export type ArgumentType<T> = SingleArgumentType<T> | ListArgumentType<T>;

export interface ArgumentOptions {
name: string;
description: string;
Expand Down
150 changes: 136 additions & 14 deletions packages/core/src/shared/util/type.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { t } from "@rbxts/t";
import { MetadataKey } from "../core/decorators";
import { ArgumentType } from "../types";
import { ArgumentType, ListArgumentType, SingleArgumentType } from "../types";
import { MetadataReflect } from "./reflect";

type TransformFn<T> = ArgumentType<T>["transform"];
type SuggestionFn<T> = ArgumentType<T>["suggestions"];

export namespace TransformResult {
export type Object<T> = { ok: true; value: T } | { ok: false; value: string };

Expand Down Expand Up @@ -40,11 +37,11 @@ export namespace TransformResult {
* A helper class for building argument types.
*/
export class TypeBuilder<T> {
protected expensive = false;
protected marked = false;
protected validationFn?: t.check<T>;
protected transformFn?: TransformFn<T>;
protected suggestionFn?: SuggestionFn<T>;
private expensive = false;
private marked = false;
private validationFn?: t.check<T>;
private transformFn?: SingleArgumentType<T>["transform"];
private suggestionFn?: SingleArgumentType<T>["suggestions"];

protected constructor(protected readonly name: string) {}

Expand All @@ -67,7 +64,7 @@ export class TypeBuilder<T> {
* @param argumentType - The type to extend from.
* @returns A {@link TypeBuilder} instance.
*/
static extend<T>(name: string, argumentType: ArgumentType<T>) {
static extend<T>(name: string, argumentType: SingleArgumentType<T>) {
const builder = new TypeBuilder<T>(name);
builder.expensive = argumentType.expensive;
builder.validationFn = argumentType.validate;
Expand Down Expand Up @@ -98,7 +95,131 @@ export class TypeBuilder<T> {
* @param expensive - Whether the function is expensive.
* @returns The {@link TypeBuilder} instance.
*/
transform(fn: TransformFn<T>, expensive = false) {
transform(fn: SingleArgumentType<T>["transform"], expensive = false) {
this.transformFn = fn;
this.expensive = expensive;
return this;
}

/**
* Sets the suggestion function for this type.
*
* This function provides a list of suggestions for the type.
*
* @param fn - The suggestions function.
* @returns The {@link TypeBuilder} instance.
*/
suggestions(fn: SingleArgumentType<T>["suggestions"]) {
this.suggestionFn = fn;
return this;
}

/**
* Marks the type for registration.
*
* @returns The {@link TypeBuilder} instance.
*/
markForRegistration() {
this.marked = true;
return this;
}

/**
* Builds the type, returning an {@link ArgumentType} object.
*
* If the type has been marked for registration through {@link markForRegistration}, it will be added to
* the list of objects that will be registered when `register()` is called.
*
* @throws Will throw an error if the required functions were not defined
* @returns An {@link ArgumentType} object.
*/
build(): SingleArgumentType<T> {
assert(this.validationFn !== undefined, "Validation function is required");
assert(this.transformFn !== undefined, "Transform function is required");

const argType = {
kind: "single",
name: this.name,
expensive: this.expensive,
validate: this.validationFn,
transform: this.transformFn,
suggestions: this.suggestionFn,
} satisfies SingleArgumentType<T>;

if (this.marked) {
MetadataReflect.defineMetadata(argType, MetadataKey.Type, true);
}

return argType;
}
}

/**
* A helper class for building list argument types.
*/
export class ListTypeBuilder<T extends defined[]> {
private expensive = false;
private marked = false;
private validationFn?: t.check<T>;
private transformFn?: ListArgumentType<T>["transform"];
private suggestionFn?: ListArgumentType<T>["suggestions"];

private constructor(protected readonly name: string) {}

/**
* Instantiates a {@link TypeBuilder} with the given name.
*
*
* @param name - The name of the type.
* @returns A {@link TypeBuilder} instance.
*/
static create<T extends defined[]>(name: string) {
return new ListTypeBuilder<T>(name);
}

/**
* Creates a new `TypeBuilder` with the given name, extending
* from the provided type.
*
* @param name - The name of the type.
* @param argumentType - The type to extend from.
* @returns A {@link TypeBuilder} instance.
*/
static extend<T extends defined[]>(
name: string,
argumentType: ListArgumentType<T>,
) {
const builder = new ListTypeBuilder<T>(name);
builder.expensive = argumentType.expensive;
builder.validationFn = argumentType.validate;
builder.transformFn = argumentType.transform;
builder.suggestionFn = argumentType.suggestions;
return builder;
}

/**
* Sets the validation function for this type.
*
* @param fn - The validation function.
* @returns The {@link TypeBuilder} instance.
*/
validate(fn: t.check<T>) {
this.validationFn = fn;
return this;
}

/**
* Sets the transformation function for this type.
*
* If the `expensive` parameter is `true`, it indicates the transformation
* function is expensive to compute. If the default interface is used, type-checking
* will be disabled while typing an argument.
*
* @param fn - The transformation function.
* @param expensive - Whether the function is expensive.
* @returns The {@link TypeBuilder} instance.
*/
transform(fn: ListArgumentType<T>["transform"], expensive = false) {
this.transformFn = fn;
this.expensive = expensive;
return this;
Expand All @@ -112,7 +233,7 @@ export class TypeBuilder<T> {
* @param fn - The suggestions function.
* @returns The {@link TypeBuilder} instance.
*/
suggestions(fn: SuggestionFn<T>) {
suggestions(fn: ListArgumentType<T>["suggestions"]) {
this.suggestionFn = fn;
return this;
}
Expand All @@ -136,17 +257,18 @@ export class TypeBuilder<T> {
* @throws Will throw an error if the required functions were not defined
* @returns An {@link ArgumentType} object.
*/
build(): ArgumentType<T> {
build(): ListArgumentType<T> {
assert(this.validationFn !== undefined, "Validation function is required");
assert(this.transformFn !== undefined, "Transform function is required");

const argType = {
kind: "list",
name: this.name,
expensive: this.expensive,
validate: this.validationFn,
transform: this.transformFn,
suggestions: this.suggestionFn,
} as ArgumentType<T>;
} satisfies ListArgumentType<T>;

if (this.marked) {
MetadataReflect.defineMetadata(argType, MetadataKey.Type, true);
Expand Down
11 changes: 9 additions & 2 deletions packages/ui/src/components/suggestions/suggestion-list.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Vide, { Derivable, For, read } from "@rbxts/vide";
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 { useAtom } from "../../hooks/use-atom";
import { px } from "../../hooks/use-px";
Expand All @@ -16,12 +17,17 @@ export interface SuggestionListProps {
size: Derivable<UDim2>;
}

const MAX_SUGGESTIONS = 3;

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

return (
<Group
Expand All @@ -34,7 +40,7 @@ export function SuggestionList({
Padding={() => new UDim(0, px(8))}
/>

<For each={() => read(suggestion)?.others ?? []}>
<For each={suggestions}>
{(name: string, i: () => number) => {
return (
<Frame
Expand All @@ -45,6 +51,7 @@ export function SuggestionList({
}
cornerRadius={() => new UDim(0, px(8))}
clipsDescendants={true}
layoutOrder={i}
>
<Padding all={() => new UDim(0, px(4))} />

Expand Down
Loading

0 comments on commit 69f5d5e

Please sign in to comment.