From d7e9172bccde25ad9ae2d7d5dc0e608c35372c4e Mon Sep 17 00:00:00 2001 From: paradoxuum Date: Thu, 11 Jan 2024 13:13:24 +0000 Subject: [PATCH] feat: check transformation result when typing This means that the transformation function for the current argument will be executed on the current text for the argument. If an error occurs during transformation, it will be displayed in the argument suggestion. There is also now an `expensive` flag for types, indicating if this functionality should be disabled for the type. --- src/client/interface/app/suggestion.ts | 15 +++++ .../components/terminal/TerminalTextField.tsx | 10 ++- .../components/terminal/TerminalWindow.tsx | 3 +- .../terminal/suggestion/MainSuggestion.tsx | 20 +++++- .../terminal/suggestion/Suggestions.tsx | 67 ++++++++----------- .../components/terminal/suggestion/types.ts | 3 +- .../components/terminal/suggestion/util.ts | 53 +++++++++++++++ .../interface/store/app/appSelectors.ts | 4 +- src/client/interface/store/app/appSlice.ts | 9 +-- src/client/interface/types.ts | 1 + src/shared/types.ts | 1 + src/shared/util/type.ts | 18 ++++- 12 files changed, 146 insertions(+), 58 deletions(-) diff --git a/src/client/interface/app/suggestion.ts b/src/client/interface/app/suggestion.ts index 4a8fc337..0d953740 100644 --- a/src/client/interface/app/suggestion.ts +++ b/src/client/interface/app/suggestion.ts @@ -29,6 +29,20 @@ export function getArgumentSuggestion( ); } + // 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; + try { + if (!typeObject.expensive) { + const transformResult = typeObject.transform(text ?? ""); + if (transformResult.isErr()) { + errorText = transformResult.unwrapErr(); + } + } + } catch { + errorText = "Failed to transform type"; + } + return { main: { type: "argument", @@ -36,6 +50,7 @@ export function getArgumentSuggestion( description: arg.description, dataType: typeObject.name, optional: arg.optional ?? false, + error: errorText, }, others: typeSuggestions, }; diff --git a/src/client/interface/components/terminal/TerminalTextField.tsx b/src/client/interface/components/terminal/TerminalTextField.tsx index 284a0cac..ee124d5f 100644 --- a/src/client/interface/components/terminal/TerminalTextField.tsx +++ b/src/client/interface/components/terminal/TerminalTextField.tsx @@ -25,7 +25,7 @@ import { useRem } from "../../hooks/useRem"; import { useStore } from "../../hooks/useStore"; import { CommanderContext } from "../../providers/commanderProvider"; import { SuggestionContext } from "../../providers/suggestionProvider"; -import { selectCommand, selectErrorText, selectVisible } from "../../store/app"; +import { selectCommand, selectValid, selectVisible } from "../../store/app"; import { getArgumentNames } from "../../util/argument"; import { Frame } from "../interface/Frame"; import { Padding } from "../interface/Padding"; @@ -92,14 +92,12 @@ export function TerminalTextField({ }, [appVisible]); useMountEffect(() => { - let errored = false; - store.subscribe(selectErrorText, (text) => { - errored = text !== undefined; - setValid(!errored); + store.subscribe(selectValid, (valid) => { + setValid(valid); }); store.subscribe(selectCommand, (command) => { - if (errored) return; + if (!store.getState().app.valid) return; setValid(command !== undefined); }); }); diff --git a/src/client/interface/components/terminal/TerminalWindow.tsx b/src/client/interface/components/terminal/TerminalWindow.tsx index dbf27e72..c7ad2bf3 100644 --- a/src/client/interface/components/terminal/TerminalWindow.tsx +++ b/src/client/interface/components/terminal/TerminalWindow.tsx @@ -194,8 +194,7 @@ export function TerminalWindow() { : undefined; if (commandPath !== undefined && command !== undefined) { - const argCheckMessage = checkArgs(commandPath, command); - store.setErrorText(argCheckMessage); + store.setValid(checkArgs(commandPath, command) === undefined); } } else { store.setCommand(undefined); diff --git a/src/client/interface/components/terminal/suggestion/MainSuggestion.tsx b/src/client/interface/components/terminal/suggestion/MainSuggestion.tsx index 9b631dc8..ebaee294 100644 --- a/src/client/interface/components/terminal/suggestion/MainSuggestion.tsx +++ b/src/client/interface/components/terminal/suggestion/MainSuggestion.tsx @@ -9,7 +9,7 @@ import { Group } from "../../interface/Group"; import { Padding } from "../../interface/Padding"; import { Text } from "../../interface/Text"; import { Badge } from "./Badge"; -import { SuggestionSizes } from "./types"; +import { SuggestionTextBounds } from "./types"; import { highlightMatching } from "./util"; export interface MainSuggestionProps { @@ -17,7 +17,7 @@ export interface MainSuggestionProps { argument: BindingOrValue; currentText?: string; size: BindingOrValue; - sizes: Binding; + sizes: Binding; } export function MainSuggestion({ @@ -109,6 +109,22 @@ export function MainSuggestion({ textWrapped={true} richText={true} /> + + new UDim2(1, 0, 0, val.errorTextHeight))} + position={UDim2.fromScale(0, 1)} + text={ + argument && suggestion !== undefined + ? (suggestion.main as ArgumentSuggestion).error ?? "" + : "" + } + textColor={palette.red} + textSize={rem(1.5)} + textWrapped={true} + textXAlignment="Left" + /> ); } diff --git a/src/client/interface/components/terminal/suggestion/Suggestions.tsx b/src/client/interface/components/terminal/suggestion/Suggestions.tsx index 7f8dd0fc..763099c3 100644 --- a/src/client/interface/components/terminal/suggestion/Suggestions.tsx +++ b/src/client/interface/components/terminal/suggestion/Suggestions.tsx @@ -1,4 +1,3 @@ -import { getBindingValue } from "@rbxts/pretty-react-hooks"; import { useSelector } from "@rbxts/react-reflex"; import Roact, { useBinding, @@ -7,7 +6,7 @@ import Roact, { useMemo, } from "@rbxts/roact"; import { TextService } from "@rbxts/services"; -import { DEFAULT_FONT, fonts } from "../../../constants/fonts"; +import { DEFAULT_FONT } from "../../../constants/fonts"; import { palette } from "../../../constants/palette"; import { springs } from "../../../constants/springs"; import { useMotion } from "../../../hooks/useMotion"; @@ -19,8 +18,8 @@ import { Group } from "../../interface/Group"; import { Padding } from "../../interface/Padding"; import { Text } from "../../interface/Text"; import { MainSuggestion } from "./MainSuggestion"; -import { SuggestionSizes } from "./types"; -import { highlightMatching } from "./util"; +import { SuggestionTextBounds } from "./types"; +import { getSuggestionTextBounds, highlightMatching } from "./util"; export interface SuggestionListProps { position?: UDim2; @@ -47,9 +46,10 @@ export function SuggestionList({ position }: SuggestionListProps) { // Suggestions const suggestion = useContext(SuggestionContext).suggestion; - const [sizes, setSizes] = useBinding({ + const [sizes, setSizes] = useBinding({ title: UDim2.fromOffset(rem(16), rem(2)), description: UDim2.fromOffset(rem(16), rem(2)), + errorTextHeight: 0, typeBadgeWidth: rem(6), }); @@ -72,48 +72,35 @@ export function SuggestionList({ position }: SuggestionListProps) { otherSuggestionSizeMotion.spring(new UDim2()); } - textBoundsParams.Text = mainSuggestion.title; - textBoundsParams.Font = fonts.inter.bold; - textBoundsParams.Size = rem(2); - textBoundsParams.Width = rem(16); - - const titleBounds = TextService.GetTextBoundsAsync(textBoundsParams); + const textBounds = getSuggestionTextBounds( + mainSuggestion, + rem(2), + rem(1.5), + rem(16), + rem(8), + ); - let descriptionBounds: Vector2; - if (mainSuggestion.description !== undefined) { - textBoundsParams.Text = mainSuggestion.description; - textBoundsParams.Font = DEFAULT_FONT; - textBoundsParams.Size = rem(1.5); - descriptionBounds = TextService.GetTextBoundsAsync(textBoundsParams); - } else { - descriptionBounds = new Vector2(); - } + setSizes(textBounds); - let windowWidth = math.max(titleBounds.X, descriptionBounds.X) + rem(2); - let windowHeight = titleBounds.Y + descriptionBounds.Y + rem(2); + let windowWidth = + math.max(textBounds.title.X.Offset, textBounds.description.X.Offset) + + textBounds.typeBadgeWidth + + rem(6); - // If the suggestion is an argument, calculate the data type text bounds - // and add it to the size of the suggestion window - let typeBadgeBounds: Vector2 | undefined; - if (mainSuggestion.type === "argument") { - textBoundsParams.Text = mainSuggestion.dataType; - textBoundsParams.Font = DEFAULT_FONT; - textBoundsParams.Size = rem(1.5); - textBoundsParams.Width = rem(8); + let windowHeight = + textBounds.title.Y.Offset + + textBounds.description.Y.Offset + + textBounds.errorTextHeight + + rem(2); - typeBadgeBounds = TextService.GetTextBoundsAsync(textBoundsParams); - windowWidth += typeBadgeBounds.X + rem(4); + if (textBounds.typeBadgeWidth > 0) { windowHeight += rem(1); + windowWidth += rem(0.5); } - setSizes({ - title: UDim2.fromOffset(titleBounds.X, titleBounds.Y), - description: UDim2.fromOffset(descriptionBounds.X, descriptionBounds.Y), - typeBadgeWidth: - typeBadgeBounds !== undefined - ? typeBadgeBounds.X + rem(2) - : getBindingValue(sizes).typeBadgeWidth, - }); + if (textBounds.errorTextHeight > 0) { + windowHeight += rem(0.5); + } // Calculate other suggestion sizes if (!otherSuggestions.isEmpty()) { diff --git a/src/client/interface/components/terminal/suggestion/types.ts b/src/client/interface/components/terminal/suggestion/types.ts index e08b77e1..f20335d3 100644 --- a/src/client/interface/components/terminal/suggestion/types.ts +++ b/src/client/interface/components/terminal/suggestion/types.ts @@ -1,5 +1,6 @@ -export interface SuggestionSizes { +export interface SuggestionTextBounds { title: UDim2; description: UDim2; + errorTextHeight: number; typeBadgeWidth: number; } diff --git a/src/client/interface/components/terminal/suggestion/util.ts b/src/client/interface/components/terminal/suggestion/util.ts index cd182406..202ab1fa 100644 --- a/src/client/interface/components/terminal/suggestion/util.ts +++ b/src/client/interface/components/terminal/suggestion/util.ts @@ -1,7 +1,13 @@ +import { TextService } from "@rbxts/services"; +import { DEFAULT_FONT, fonts } from "../../../constants/fonts"; import { palette } from "../../../constants/palette"; +import { ArgumentSuggestion, CommandSuggestion } from "../../../types"; import { toHex } from "../../../util/color"; +import { SuggestionTextBounds } from "./types"; const HIGHLIGHT_PREFIX = ``; +const TEXT_BOUNDS_PARAMS = new Instance("GetTextBoundsParams"); +const DEFAULT_BOUNDS = new Vector2(); export function highlightMatching(text?: string, terminalText?: string) { if (text === undefined) return ""; @@ -15,3 +21,50 @@ export function highlightMatching(text?: string, terminalText?: string) { const unhighlightedText = text.sub(terminalText.size() + 1); return `${HIGHLIGHT_PREFIX}${subText}${unhighlightedText}`; } + +export function getSuggestionTextBounds( + suggestion: ArgumentSuggestion | CommandSuggestion, + titleTextSize: number, + textSize: number, + maxWidth: number, + maxBadgeWidth: number, +): SuggestionTextBounds { + // Get title text bounds + TEXT_BOUNDS_PARAMS.Text = suggestion.title; + TEXT_BOUNDS_PARAMS.Size = titleTextSize; + TEXT_BOUNDS_PARAMS.Font = fonts.inter.bold; + TEXT_BOUNDS_PARAMS.Width = maxWidth; + const titleBounds = TextService.GetTextBoundsAsync(TEXT_BOUNDS_PARAMS); + + // Get description text bounds + TEXT_BOUNDS_PARAMS.Size = textSize; + TEXT_BOUNDS_PARAMS.Font = DEFAULT_FONT; + + let descriptionBounds = DEFAULT_BOUNDS; + if (suggestion.description !== undefined) { + TEXT_BOUNDS_PARAMS.Text = suggestion.description; + descriptionBounds = TextService.GetTextBoundsAsync(TEXT_BOUNDS_PARAMS); + } + + let errorTextHeight = 0; + let typeBadgeWidth = 0; + if (suggestion.type === "argument") { + // Get error text bounds + if (suggestion.error !== undefined) { + TEXT_BOUNDS_PARAMS.Text = suggestion.error ?? ""; + errorTextHeight = TextService.GetTextBoundsAsync(TEXT_BOUNDS_PARAMS).Y; + } + + // Get type badge bounds + TEXT_BOUNDS_PARAMS.Text = suggestion.dataType; + TEXT_BOUNDS_PARAMS.Width = maxBadgeWidth; + typeBadgeWidth = TextService.GetTextBoundsAsync(TEXT_BOUNDS_PARAMS).X; + } + + return { + title: UDim2.fromOffset(titleBounds.X, titleBounds.Y), + description: UDim2.fromOffset(descriptionBounds.X, descriptionBounds.Y), + errorTextHeight, + typeBadgeWidth, + }; +} diff --git a/src/client/interface/store/app/appSelectors.ts b/src/client/interface/store/app/appSelectors.ts index 6074d23b..8c0f1e9b 100644 --- a/src/client/interface/store/app/appSelectors.ts +++ b/src/client/interface/store/app/appSelectors.ts @@ -4,6 +4,6 @@ export const selectVisible = (state: RootState) => state.app.visible; export const selectText = (state: RootState) => state.app.text; -export const selectErrorText = (state: RootState) => state.app.errorText; - export const selectCommand = (state: RootState) => state.app.command; + +export const selectValid = (state: RootState) => state.app.valid; diff --git a/src/client/interface/store/app/appSlice.ts b/src/client/interface/store/app/appSlice.ts index 90578d7f..0db77e95 100644 --- a/src/client/interface/store/app/appSlice.ts +++ b/src/client/interface/store/app/appSlice.ts @@ -16,7 +16,7 @@ export interface AppState { parts: string[]; index: number; }; - errorText?: string; + valid: boolean; } export const initialAppState: AppState = { @@ -28,10 +28,11 @@ export const initialAppState: AppState = { parts: [], index: -1, }, + valid: false, }; /** - * Limits an array by removing the first n (limit) elments if + * Limits an array by removing the first n (limit) elements if * the array's size exceeds the limit. * * @param array The array to limit @@ -88,8 +89,8 @@ export const appSlice = createProducer(initialAppState, { }; }, - setErrorText: (state, text?: string) => ({ + setValid: (state, valid: boolean) => ({ ...state, - errorText: text, + valid, }), }); diff --git a/src/client/interface/types.ts b/src/client/interface/types.ts index 0099f07e..1e9ad606 100644 --- a/src/client/interface/types.ts +++ b/src/client/interface/types.ts @@ -18,6 +18,7 @@ export interface ArgumentSuggestion { description?: string; dataType: string; optional: boolean; + error?: string; } export interface Suggestion { diff --git a/src/shared/types.ts b/src/shared/types.ts index 2759ec6b..fd554d89 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -16,6 +16,7 @@ export type TransformationResult = Result; export interface TypeOptions { name: string; + expensive: boolean; validate: t.check; transform: (text: string) => TransformationResult; suggestions?: (text: string) => string[]; diff --git a/src/shared/util/type.ts b/src/shared/util/type.ts index c5b7a230..472b17c6 100644 --- a/src/shared/util/type.ts +++ b/src/shared/util/type.ts @@ -23,6 +23,7 @@ export namespace TransformResult { } export class TypeBuilder { + private expensive = false; private validationFn?: t.check; private transformFn?: (text: string) => TransformationResult; private suggestionFn?: (text: string) => string[]; @@ -30,6 +31,8 @@ export class TypeBuilder { private constructor(private readonly name: string) {} /** + * Creates a {@link TypeBuilder}. + * * @param name - The name of the type * @returns A type builder */ @@ -44,6 +47,7 @@ export class TypeBuilder { */ static extend(name: string, options: TypeOptions) { const builder = new TypeBuilder(name); + builder.expensive = options.expensive; builder.validationFn = options.validate; builder.transformFn = options.transform; builder.suggestionFn = options.suggestions; @@ -64,11 +68,21 @@ export class TypeBuilder { /** * Sets the transformation function for this type. * + * If the `expensive` parameter is set to true, it indicates that + * the transform function will be expensive to compute. If the default + * interface is being used in this case, it will disable real-time + * checking while the user is typing the command. + * * @param transformFn - The transformation function for this type + * @param expensive - Whether the function is expensive to compute * @returns The type builder */ - transform(transformFn: (text: string) => TransformationResult) { + transform( + transformFn: (text: string) => TransformationResult, + expensive = false, + ) { this.transformFn = transformFn; + this.expensive = expensive; return this; } @@ -95,8 +109,10 @@ export class TypeBuilder { build(): TypeOptions { assert(this.validationFn !== undefined, "Validation function is required"); assert(this.transformFn !== undefined, "Transform function is required"); + return { name: this.name, + expensive: this.expensive, validate: this.validationFn, transform: this.transformFn, suggestions: this.suggestionFn,