From 69f5d5e522c9f7787625270d3695f8f5489c1de5 Mon Sep 17 00:00:00 2001 From: paradoxuum Date: Sat, 3 Aug 2024 14:17:24 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20list=20types=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/src/shared/builtin/types/players.ts | 24 +-- packages/core/src/shared/core/command.ts | 20 ++- packages/core/src/shared/types.ts | 14 +- packages/core/src/shared/util/type.ts | 150 ++++++++++++++++-- .../suggestions/suggestion-list.tsx | 11 +- .../ui/src/components/terminal/suggestion.ts | 141 ++++++++-------- .../terminal/terminal-text-field.tsx | 80 +++++----- .../ui/src/components/terminal/terminal.tsx | 81 +++++++--- packages/ui/src/store.ts | 31 +--- 9 files changed, 350 insertions(+), 202 deletions(-) diff --git a/packages/core/src/shared/builtin/types/players.ts b/packages/core/src/shared/builtin/types/players.ts index 59fde6f7..d897d499 100644 --- a/packages/core/src/shared/builtin/types/players.ts +++ b/packages/core/src/shared/builtin/types/players.ts @@ -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, @@ -41,30 +41,32 @@ const playerType = TypeBuilder.create(CenturionType.Player) .suggestions(getPlayerSuggestions) .build(); -const PlayersType = TypeBuilder.create(CenturionType.Players) +const playersType = ListTypeBuilder.create(CenturionType.Players) .validate(t.array(isPlayer)) - .transform((text, executor) => { + .transform((input, executor) => { + const includedPlayers = new Set(); 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(() => { @@ -75,5 +77,5 @@ const PlayersType = TypeBuilder.create(CenturionType.Players) .build(); export = (registry: BaseRegistry) => { - registry.registerType(playerType, PlayersType); + registry.registerType(playerType, playersType); }; diff --git a/packages/core/src/shared/core/command.ts b/packages/core/src/shared/core/command.ts index af762522..cedafd67 100644 --- a/packages/core/src/shared/core/command.ts +++ b/packages/core/src/shared/core/command.ts @@ -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"; @@ -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; + 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] = diff --git a/packages/core/src/shared/types.ts b/packages/core/src/shared/types.ts index b0e8277e..3147f4ec 100644 --- a/packages/core/src/shared/types.ts +++ b/packages/core/src/shared/types.ts @@ -21,7 +21,8 @@ export interface RegisterOptions { groups?: GroupOptions[]; } -export interface ArgumentType { +export interface SingleArgumentType { + kind: "single"; name: string; expensive: boolean; validate: t.check; @@ -29,6 +30,17 @@ export interface ArgumentType { suggestions?: (text: string, executor?: Player) => string[]; } +export interface ListArgumentType { + kind: "list"; + name: string; + expensive: boolean; + validate: t.check; + transform: (input: string[], executor?: Player) => TransformResult.Object; + suggestions?: (input: string[], executor?: Player) => string[]; +} + +export type ArgumentType = SingleArgumentType | ListArgumentType; + export interface ArgumentOptions { name: string; description: string; diff --git a/packages/core/src/shared/util/type.ts b/packages/core/src/shared/util/type.ts index 06308ad4..727d06f3 100644 --- a/packages/core/src/shared/util/type.ts +++ b/packages/core/src/shared/util/type.ts @@ -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 = ArgumentType["transform"]; -type SuggestionFn = ArgumentType["suggestions"]; - export namespace TransformResult { export type Object = { ok: true; value: T } | { ok: false; value: string }; @@ -40,11 +37,11 @@ export namespace TransformResult { * A helper class for building argument types. */ export class TypeBuilder { - protected expensive = false; - protected marked = false; - protected validationFn?: t.check; - protected transformFn?: TransformFn; - protected suggestionFn?: SuggestionFn; + private expensive = false; + private marked = false; + private validationFn?: t.check; + private transformFn?: SingleArgumentType["transform"]; + private suggestionFn?: SingleArgumentType["suggestions"]; protected constructor(protected readonly name: string) {} @@ -67,7 +64,7 @@ export class TypeBuilder { * @param argumentType - The type to extend from. * @returns A {@link TypeBuilder} instance. */ - static extend(name: string, argumentType: ArgumentType) { + static extend(name: string, argumentType: SingleArgumentType) { const builder = new TypeBuilder(name); builder.expensive = argumentType.expensive; builder.validationFn = argumentType.validate; @@ -98,7 +95,131 @@ export class TypeBuilder { * @param expensive - Whether the function is expensive. * @returns The {@link TypeBuilder} instance. */ - transform(fn: TransformFn, expensive = false) { + transform(fn: SingleArgumentType["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["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 { + 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; + + if (this.marked) { + MetadataReflect.defineMetadata(argType, MetadataKey.Type, true); + } + + return argType; + } +} + +/** + * A helper class for building list argument types. + */ +export class ListTypeBuilder { + private expensive = false; + private marked = false; + private validationFn?: t.check; + private transformFn?: ListArgumentType["transform"]; + private suggestionFn?: ListArgumentType["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(name: string) { + return new ListTypeBuilder(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( + name: string, + argumentType: ListArgumentType, + ) { + const builder = new ListTypeBuilder(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) { + 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["transform"], expensive = false) { this.transformFn = fn; this.expensive = expensive; return this; @@ -112,7 +233,7 @@ export class TypeBuilder { * @param fn - The suggestions function. * @returns The {@link TypeBuilder} instance. */ - suggestions(fn: SuggestionFn) { + suggestions(fn: ListArgumentType["suggestions"]) { this.suggestionFn = fn; return this; } @@ -136,17 +257,18 @@ export class TypeBuilder { * @throws Will throw an error if the required functions were not defined * @returns An {@link ArgumentType} object. */ - build(): ArgumentType { + build(): ListArgumentType { 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; + } satisfies ListArgumentType; if (this.marked) { MetadataReflect.defineMetadata(argType, MetadataKey.Type, true); diff --git a/packages/ui/src/components/suggestions/suggestion-list.tsx b/packages/ui/src/components/suggestions/suggestion-list.tsx index 06539916..83e289ca 100644 --- a/packages/ui/src/components/suggestions/suggestion-list.tsx +++ b/packages/ui/src/components/suggestions/suggestion-list.tsx @@ -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"; @@ -16,12 +17,17 @@ export interface SuggestionListProps { size: Derivable; } +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 ( new UDim(0, px(8))} /> - read(suggestion)?.others ?? []}> + {(name: string, i: () => number) => { return ( new UDim(0, px(8))} clipsDescendants={true} + layoutOrder={i} > new UDim(0, px(4))} /> diff --git a/packages/ui/src/components/terminal/suggestion.ts b/packages/ui/src/components/terminal/suggestion.ts index bb34c04f..4b15d463 100644 --- a/packages/ui/src/components/terminal/suggestion.ts +++ b/packages/ui/src/components/terminal/suggestion.ts @@ -1,94 +1,89 @@ import { ArgumentOptions, - ArgumentType, BaseRegistry, CommandOptions, + ListArgumentType, RegistryPath, + SingleArgumentType, } from "@rbxts/centurion"; -import { ArrayUtil, ReadonlyDeep } from "@rbxts/centurion/out/shared/util/data"; +import { ReadonlyDeep } from "@rbxts/centurion/out/shared/util/data"; import { Players } from "@rbxts/services"; import { ArgumentSuggestion, CommandSuggestion } from "../../types"; const MAX_OTHER_SUGGESTIONS = 3; -function getSortedIndices(max: number, strings: string[], text?: string) { - // If no text is provided, sort alphabetically +export interface SingleArgument { + kind: "single"; + options: ReadonlyDeep; + type: SingleArgumentType; + input?: string; +} + +export interface ListArgument { + kind: "list"; + options: ReadonlyDeep; + type: ListArgumentType; + input: string[]; +} + +export type Argument = SingleArgument | ListArgument; + +function getMatches( + strings: string[], + text?: string, +): [number, string, number][] { if (text === undefined) { - const sorted = [...strings].sort().map((_, index) => index); - return ArrayUtil.slice(sorted, 0, math.min(sorted.size(), max)); + return strings.sort().map((str, i) => [i, str, str.size()]); } - // Otherwise, sort by the closest match const textLower = text.lower(); const textEndIndex = text.size(); - - const results: Array<[number, number]> = []; - for (const i of $range(0, strings.size() - 1)) { - const part = strings[i].lower().sub(0, textEndIndex); - if (part === textLower) { - results.push([part.size(), i]); - } - } - - results.sort((a, b) => { - if (a[0] === b[0]) { - return strings[a[1]] < strings[b[1]]; - } - return a[0] < b[0]; - }); - - return ArrayUtil.slice(results, 0, math.min(results.size(), max)).map( - (val) => val[1], - ); + return strings + .mapFiltered<[number, string, number] | undefined>((str, i) => { + const part = str.lower().sub(0, textEndIndex); + if (part === textLower) return [i, str, str.size()]; + }) + .sort((a, b) => a[1] < b[1]); } -export function getArgumentSuggestion( - arg: ReadonlyDeep, - argType: ArgumentType, - text?: string, -): ArgumentSuggestion | undefined { - const argSuggestions = - arg.suggestions !== undefined ? [...arg.suggestions] : []; - if (argType.suggestions !== undefined) { - for (const suggestion of argType.suggestions( - text ?? "", - Players.LocalPlayer, - )) { - argSuggestions.push(suggestion); +export function getArgumentSuggestion(arg: Argument, textPart?: string) { + const suggestions = [...(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); } } - // If the type is not marked as "expensive", transform the text into the type - // If the transformation fails, include the error message in the suggestion let errorText: string | undefined; - const [success, err] = pcall(() => { - if (argType.expensive) return; - const transformResult = argType.transform(text ?? "", Players.LocalPlayer); - - if (transformResult.ok) return; - errorText = transformResult.value; - }); - - if (!success) { - errorText = "Failed to transform argument"; - warn(err); + if (!arg.type.expensive) { + const [success, err] = pcall(() => { + const transformResult = singleArg + ? arg.type.transform(arg.input ?? "", Players.LocalPlayer) + : arg.type.transform(arg.input, Players.LocalPlayer); + if (transformResult.ok) return; + errorText = transformResult.value; + }); + + if (!success) { + errorText = "Failed to transform argument"; + warn(err); + } } - const otherSuggestions = getSortedIndices( - MAX_OTHER_SUGGESTIONS, - argSuggestions, - text, - ).map((index) => argSuggestions[index]); - return { type: "argument", - title: arg.name, - others: otherSuggestions, - description: arg.description, - dataType: argType.name, - optional: arg.optional ?? false, + 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( @@ -103,28 +98,20 @@ export function getCommandSuggestion( if (paths.isEmpty()) return; const pathNames = paths.map((path) => path.tail()); - const sortedPaths = getSortedIndices( - MAX_OTHER_SUGGESTIONS + 1, - pathNames, - text, - ); - if (sortedPaths.isEmpty()) return; + const sortedPaths = getMatches(pathNames, text); + const firstMatch = sortedPaths.remove(0); + if (firstMatch === undefined) return; - const firstPath = paths[sortedPaths[0]]; + const firstPath = paths[firstMatch[0]]; const mainData = registry.getCommand(firstPath)?.options ?? registry.getGroup(firstPath)?.options; if (mainData === undefined) return; - const otherNames = - sortedPaths.size() > 1 - ? ArrayUtil.slice(sortedPaths, 1).map((index) => pathNames[index]) - : []; - return { type: "command", title: firstPath.tail(), - others: otherNames, + others: sortedPaths.map(([, str]) => str), description: mainData.description, shortcuts: (mainData as CommandOptions).shortcuts, }; diff --git a/packages/ui/src/components/terminal/terminal-text-field.tsx b/packages/ui/src/components/terminal/terminal-text-field.tsx index eaa78ea8..69e78c86 100644 --- a/packages/ui/src/components/terminal/terminal-text-field.tsx +++ b/packages/ui/src/components/terminal/terminal-text-field.tsx @@ -10,11 +10,13 @@ import { useAtom } from "../../hooks/use-atom"; import { useEvent } from "../../hooks/use-event"; import { px } from "../../hooks/use-px"; import { - currentArgIndex, + commandArgIndex, currentCommandPath, currentSuggestion, + currentTextPart, interfaceOptions, interfaceVisible, + terminalArgIndex, terminalText, terminalTextParts, terminalTextValid, @@ -104,18 +106,22 @@ export function TerminalTextField({ const atNextPart = endsWithSpace(terminalText()); const textParts = terminalTextParts(); - const suggestionStartIndex = - textParts.size() > 0 - ? (!atNextPart ? textParts[textParts.size() - 1].size() : 0) + 1 - : -1; + if (textParts.isEmpty()) { + suggestionText(suggestion.title); + return; + } - if (suggestion.type === "command" && suggestionStartIndex > -1) { + // Command suggestions + if (suggestion.type === "command") { + const suggestionStartIndex = + (!atNextPart ? textParts[textParts.size() - 1].size() : 0) + 1; suggestionText(text() + suggestion.title.sub(suggestionStartIndex)); return; } + // Argument suggestions const command = currentCommandPath(); - const argIndex = currentArgIndex(); + const argIndex = terminalArgIndex(); if ( suggestion.type !== "argument" || command === undefined || @@ -125,18 +131,15 @@ export function TerminalTextField({ } let newText = text(); - const argNames = getArgumentNames(api.registry, command); - for (const i of $range(argIndex, argNames.size() - 1)) { - if (i === argIndex && !atNextPart) { - if (!suggestion.others.isEmpty()) { - newText += suggestion.others[0].sub(suggestionStartIndex); - } - - newText += " "; - continue; - } + if (atNextPart && argIndex === commandArgIndex()) { + newText += suggestion.title; + } else if (!suggestion.others.isEmpty()) { + newText += suggestion.others[0].sub((currentTextPart()?.size() ?? 0) + 1); + } - newText = `${newText}${argNames[i]} `; + const argNames = getArgumentNames(api.registry, command); + for (const i of $range(argIndex + 1, argNames.size() - 1)) { + newText = `${newText} ${argNames[i]}`; } suggestionText(newText); }); @@ -155,35 +158,38 @@ export function TerminalTextField({ if (input.KeyCode !== Enum.KeyCode.Tab) return; - // Handle command suggestions + // Command suggestions const commandPath = currentCommandPath(); const suggestion = currentSuggestion(); - if (commandPath === undefined) { - const suggestionTitle = suggestion?.title; - if (suggestionTitle === undefined) return; + if (suggestion === undefined) return; + if (commandPath === undefined) { + const suggestionTitle = suggestion.title; const currentText = text(); const textParts = terminalTextParts(); + if (textParts.isEmpty()) return; + + const pathParts = [...textParts]; - let newText = ""; + let newText = currentText; if (endsWithSpace(currentText)) { - newText = currentText + suggestionTitle; + newText += suggestionTitle; + pathParts.push(suggestionTitle); } else if (!textParts.isEmpty()) { - const textPartSize = textParts[textParts.size() - 1].size(); + const lastPartSize = textParts[textParts.size() - 1]; newText = - currentText.sub(0, currentText.size() - textPartSize) + + newText.sub(0, newText.size() - lastPartSize.size()) + suggestionTitle; + pathParts.remove(textParts.size() - 1); + pathParts.push(suggestionTitle); } - const suggestionTextParts = suggestionText() - .gsub("%s+", " ")[0] - .split(" "); const nextCommand = api.registry.getCommandByString( - formatPartsAsPath(suggestionTextParts), + formatPartsAsPath(pathParts), )?.options; if ( nextCommand === undefined || - (nextCommand.arguments?.size() ?? 0) > 0 + !(nextCommand.arguments?.isEmpty() ?? true) ) { newText += " "; } @@ -194,7 +200,7 @@ export function TerminalTextField({ return; } - // Handle argument suggestions + // Argument suggestions if ( commandPath === undefined || suggestion === undefined || @@ -203,23 +209,17 @@ export function TerminalTextField({ return; } - const argIndex = currentArgIndex(); + const argIndex = terminalArgIndex(); const commandArgs = api.registry.getCommand(commandPath)?.options.arguments; if (argIndex === undefined || commandArgs === undefined) return; let newText = text(); - - const parts = terminalTextParts(); - if (!endsWithSpace(newText) && !parts.isEmpty()) { - newText = newText.sub(0, newText.size() - parts[parts.size() - 1].size()); - } - let otherSuggestion = suggestion.others[0]; if (string.match(otherSuggestion, "%s")[0] !== undefined) { otherSuggestion = `"${otherSuggestion}"`; } - newText += otherSuggestion; + newText += otherSuggestion.sub((currentTextPart()?.size() ?? 0) + 1); if (argIndex < commandArgs.size() - 1) { newText += " "; } diff --git a/packages/ui/src/components/terminal/terminal.tsx b/packages/ui/src/components/terminal/terminal.tsx index 7d5b22b3..e1976f35 100644 --- a/packages/ui/src/components/terminal/terminal.tsx +++ b/packages/ui/src/components/terminal/terminal.tsx @@ -1,6 +1,9 @@ import { ArgumentOptions } from "@rbxts/centurion"; import { ArrayUtil, ReadonlyDeep } from "@rbxts/centurion/out/shared/util/data"; -import { endsWithSpace } from "@rbxts/centurion/out/shared/util/string"; +import { + endsWithSpace, + splitString, +} from "@rbxts/centurion/out/shared/util/string"; import Vide, { effect, source } from "@rbxts/vide"; import { HISTORY_TEXT_SIZE } from "../../constants/text"; import { getAPI } from "../../hooks/use-api"; @@ -9,15 +12,18 @@ import { useHistory } from "../../hooks/use-history"; import { useMotion } from "../../hooks/use-motion"; import { px } from "../../hooks/use-px"; import { - currentArgIndex, + commandArgIndex, currentCommandPath, currentSuggestion, + currentTextPart, interfaceOptions, mouseOverInterface, + terminalArgIndex, terminalText, terminalTextParts, terminalTextValid, } from "../../store"; +import { ArgumentSuggestion } from "../../types"; import { HistoryList } from "../history"; import { Frame } from "../ui/frame"; import { Padding } from "../ui/padding"; @@ -84,7 +90,7 @@ export function Terminal() { if (parts.isEmpty()) { currentCommandPath(undefined); currentSuggestion(undefined); - currentArgIndex(undefined); + terminalArgIndex(undefined); return; } @@ -111,11 +117,15 @@ export function Terminal() { terminalTextValid(false); } - const currentTextPart = !atNextPart - ? parts[parts.size() - 1] - : undefined; + const textPart = !atNextPart ? parts[parts.size() - 1] : undefined; + + const argIndex = + path !== undefined + ? parts.size() - path.size() - (atNextPart ? 0 : 1) + : -1; + terminalArgIndex(argIndex); - if (path === undefined || command === undefined) { + if (command === undefined || argIndex === -1) { // Get command suggestions const index = parts.size() - (atNextPart ? 1 : 2); const parentPath = path?.slice(0, index); @@ -123,27 +133,29 @@ export function Terminal() { getCommandSuggestion( api.registry, !parentPath?.isEmpty() ? parentPath : undefined, - currentTextPart, + textPart, ), ); + currentTextPart(textPart); return; } // Handle arguments - const argIndex = parts.size() - path.size() - (atNextPart ? 0 : 1); - if (command === undefined || argIndex === -1) return; - const args = command.options.arguments; - if (args === undefined || argIndex === -1) { + if (args === undefined) { currentSuggestion(undefined); + currentTextPart(undefined); + commandArgIndex(undefined); return; } - let index = 0; + let index = -1; let currentArg: ReadonlyDeep | 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; @@ -152,23 +164,50 @@ export function Terminal() { currentArg = args[index]; const numArgs = currentArg.numArgs ?? 1; endIndex = numArgs !== "rest" ? i + (numArgs - 1) : undefined; - index += 1; } const argType = api.registry.getType(currentArg?.type ?? ""); if (currentArg === undefined || argType === undefined) { - currentArgIndex(undefined); currentSuggestion(undefined); + currentTextPart(undefined); + terminalArgIndex(undefined); + commandArgIndex(undefined); return; } - const suggestion = getArgumentSuggestion( - currentArg, - argType, - currentTextPart, - ); + commandArgIndex(index); + + let argTextPart: string | undefined; + let suggestion: ArgumentSuggestion; + if (argType.kind === "single") { + argTextPart = textPart; + suggestion = getArgumentSuggestion( + { + kind: "single", + options: currentArg, + type: argType, + input: textPart, + }, + textPart, + ); + } else { + const textParts = splitString(textPart ?? "", ","); + const lastPart = !textParts.isEmpty() + ? textParts[textParts.size() - 1] + : undefined; + argTextPart = textPart?.sub(-1) !== "," ? lastPart : undefined; + suggestion = getArgumentSuggestion( + { + kind: "list", + options: currentArg, + type: argType, + input: textParts, + }, + argTextPart, + ); + } - currentArgIndex(argIndex); + currentTextPart(argTextPart); currentSuggestion(suggestion); if (suggestion?.error !== undefined) terminalTextValid(false); }} diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts index 1927835b..e6e4b978 100644 --- a/packages/ui/src/store.ts +++ b/packages/ui/src/store.ts @@ -11,38 +11,13 @@ export const mouseOverInterface = atom(false); export const currentCommandPath = atom( undefined, ); -export const currentArgIndex = atom(undefined); +export const commandArgIndex = atom(undefined); +export const terminalArgIndex = atom(undefined); export const currentSuggestion = atom(undefined); export const terminalText = atom(""); export const terminalTextParts = computed(() => { return splitString(terminalText(), " "); }); -export const currentTextPart = computed(() => { - const text = terminalText(); - const textParts = terminalTextParts(); - - const endsWithSpace = textParts.size() > 0 && text.match("%s$").size() > 0; - const index = endsWithSpace ? textParts.size() : textParts.size() - 1; - if (index === -1 || index >= textParts.size()) return; - return textParts[index]; -}); export const terminalTextValid = atom(false); - -export const argText = atom(undefined); -export const argTextParts = computed(() => { - const text = argText(); - if (text === undefined) return []; - return splitString(text, ","); -}); -export const currentArgPart = computed(() => { - const text = argText(); - if (text === undefined) return; - - const textParts = argTextParts(); - - const endsWithSeparator = textParts.size() > 0 && text.match(",$").size() > 0; - const index = endsWithSeparator ? textParts.size() : textParts.size() - 1; - if (index === -1 || index >= textParts.size()) return; - return textParts[index]; -}); +export const currentTextPart = atom(undefined);