diff --git a/aftman.toml b/aftman.toml index 921bc075..9cd11f64 100644 --- a/aftman.toml +++ b/aftman.toml @@ -3,4 +3,4 @@ # To add a new tool, add an entry to this table. [tools] -rojo = "rojo-rbx/rojo@7.4.0-rc3" +rojo = "rojo-rbx/rojo@7.4.0" diff --git a/package.json b/package.json index c69d4ddc..6e62be61 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@biomejs/biome": "1.4.1", "@rbxts/compiler-types": "2.2.0-types.0", "@rbxts/midori": "^0.1.3", - "@rbxts/types": "^1.0.738", + "@rbxts/types": "^1.0.747", "roblox-ts": "^2.2.0", "typescript": "=5.2" }, @@ -48,8 +48,8 @@ "@rbxts/ripple": "^0.7.1", "@rbxts/roact": "npm:@rbxts/react-ts@^1.0.1", "@rbxts/rust-classes": "^0.12.0", - "@rbxts/services": "^1.5.1", + "@rbxts/services": "^1.5.3", "@rbxts/sift": "^0.0.8", - "@rbxts/t": "^3.1.0" + "@rbxts/t": "^3.1.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f5925b5..d6dca2a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,14 +30,14 @@ dependencies: specifier: ^0.12.0 version: 0.12.0 '@rbxts/services': - specifier: ^1.5.1 - version: 1.5.1 + specifier: ^1.5.3 + version: 1.5.3 '@rbxts/sift': specifier: ^0.0.8 version: 0.0.8 '@rbxts/t': - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.1.1 + version: 3.1.1 devDependencies: '@biomejs/biome': @@ -50,8 +50,8 @@ devDependencies: specifier: ^0.1.3 version: 0.1.3 '@rbxts/types': - specifier: ^1.0.738 - version: 1.0.738 + specifier: ^1.0.747 + version: 1.0.747 roblox-ts: specifier: ^2.2.0 version: 2.2.0 @@ -155,7 +155,7 @@ packages: '@rbxts/flipper': 2.0.1 '@rbxts/react-roblox': 0.2.0 '@rbxts/roact': /@rbxts/react-ts@1.0.1 - '@rbxts/services': 1.5.1 + '@rbxts/services': 1.5.3 '@rbxts/set-timeout': 1.1.2 dev: false @@ -249,14 +249,14 @@ packages: '@rbxts/shared-internal': 0.2.1 dev: false - /@rbxts/services@1.5.1: - resolution: {integrity: sha512-SRtfIjga0K4YYSXRpK+eH3kcTq7ZXo9OHOt0jszaOOoEOIJloMGlyuRPqesPHyhveh2AMXAZr3TYbRMSD+u+kQ==} + /@rbxts/services@1.5.3: + resolution: {integrity: sha512-0Lr1OJCfhCILu0Z2+W2Hhx4nIRWLW3llSvzPskozv9AlBR99qTWrhJlh/4dux1RciKPFbZiTYi1CP+/kCloIQw==} dev: false /@rbxts/set-timeout@1.1.2: resolution: {integrity: sha512-P/A0IiH9wuZdSJYr4Us0MDFm61nvIFR0acfKFHLkcOsgvIgELC90Up9ugiSsaMEHRIcIcO5UjE39LuS3xTzQHw==} dependencies: - '@rbxts/services': 1.5.1 + '@rbxts/services': 1.5.3 dev: false /@rbxts/shared-internal@0.2.1: @@ -269,12 +269,12 @@ packages: resolution: {integrity: sha512-PCwc7MFbJwUgy4pCGz6HEW0NAhNuL87dELx/5WIRIn0G5xIQ3L/Ij1huv+zRYmnNr3+ludR6zhwKqh+irAY8+g==} dev: false - /@rbxts/t@3.1.0: - resolution: {integrity: sha512-ba/wfKYtAL6JWVhZNbhMnBZtzgGaTxttvfzxxpEcHDy0Qeaq0DF2Odo/zJ32Ajw961EMYkAMTnPXLLrLB/r+mw==} + /@rbxts/t@3.1.1: + resolution: {integrity: sha512-r+IvHHGLt9ZM8+cJAs5Q7ZyGaSlsbkXTaeat85FxRmjjozykHLbQ24rY/5utJbp63a5uCy3Wyl1fGv/Gk/GnIw==} dev: false - /@rbxts/types@1.0.738: - resolution: {integrity: sha512-3DXmizqp7sY+yQ0CUmifwEddngXjgQHM7k9CaYxeNvYzLa2XIhPVfs1DnmNf8dsv8I7UMTzV5QV/co3yeIVDTA==} + /@rbxts/types@1.0.747: + resolution: {integrity: sha512-B9Pa/b7oTvyjgWYIYB6xnsXd8PaFnIRM5xN1WpIMsqYZe2mLTmzlE5T9s2wn+dpSN1Ct9thPtA3oUFnZ7LOY9A==} dev: true /@roblox-ts/luau-ast@1.0.10: diff --git a/src/client/interface/app/app.tsx b/src/client/interface/app/app.tsx index e4a323bc..ed413f72 100644 --- a/src/client/interface/app/app.tsx +++ b/src/client/interface/app/app.tsx @@ -7,16 +7,6 @@ import { AppContext } from "../../types"; import { Layer } from "../components/interface/Layer"; import Terminal from "../components/terminal/Terminal"; import { RootProvider } from "../providers/rootProvider"; -import { SuggestionQuery } from "../types"; -import { getArgumentSuggestion, getCommandSuggestion } from "./suggestion"; - -function getSuggestion(query: SuggestionQuery) { - if (query.type === "argument") { - return getArgumentSuggestion(query.commandPath, query.index, query.text); - } - - return getCommandSuggestion(query.parentPath, query.text); -} export function CommanderApp(data: AppContext) { const root = createRoot(new Instance("Folder")); @@ -25,11 +15,7 @@ export function CommanderApp(data: AppContext) { root.render( createPortal( - + diff --git a/src/client/interface/components/terminal/TerminalTextField.tsx b/src/client/interface/components/terminal/TerminalTextField.tsx index 284a0cac..45840786 100644 --- a/src/client/interface/components/terminal/TerminalTextField.tsx +++ b/src/client/interface/components/terminal/TerminalTextField.tsx @@ -24,8 +24,10 @@ import { palette } from "../../constants/palette"; 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 { selectVisible } from "../../store/app"; +import { selectCommand } from "../../store/command"; +import { selectCurrentSuggestion } from "../../store/suggestion"; +import { selectValid } from "../../store/text"; import { getArgumentNames } from "../../util/argument"; import { Frame } from "../interface/Frame"; import { Padding } from "../interface/Padding"; @@ -50,19 +52,19 @@ export function TerminalTextField({ const rem = useRem(); const ref = useRef(); const data = useContext(CommanderContext); - const suggestion = useContext(SuggestionContext).suggestion; const store = useStore(); const appVisible = useSelector(selectVisible); + const currentSuggestion = useSelector(selectCurrentSuggestion); const [text, setText] = useBinding(""); const [suggestionText, setSuggestionText] = useBinding(""); const [valid, setValid] = useState(false); const traverseHistory = useCallback((up: boolean) => { - const history = store.getState().app.commandHistory; + const history = store.getState().history.commandHistory; if (history.isEmpty()) return; - const historyIndex = store.getState().app.commandHistoryIndex; + const historyIndex = store.getState().history.commandHistoryIndex; if ((up && historyIndex === 0) || (!up && historyIndex === -1)) return; let newIndex: number; @@ -92,32 +94,31 @@ 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().text.valid) return; setValid(command !== undefined); }); }); useEffect(() => { - if (suggestion === undefined) { + if (currentSuggestion === undefined) { setSuggestionText(""); return; } - const parts = store.getState().app.text.parts; - const atNextPart = endsWithSpace(store.getState().app.text.value); + const state = store.getState(); + const parts = state.text.parts; + const atNextPart = endsWithSpace(state.text.value); let newText = getBindingValue(text); - const command = store.getState().app.command; - const argIndex = store.getState().app.argIndex; + const command = state.command.path; + const argIndex = state.command.argIndex; if ( - suggestion.main.type === "argument" && + currentSuggestion.main.type === "argument" && command !== undefined && argIndex !== undefined ) { @@ -131,14 +132,14 @@ export function TerminalTextField({ newText = `${newText}${argNames[i]} `; } - } else if (suggestion.main.type === "command") { + } else if (currentSuggestion.main.type === "command") { const suggestionStartIndex = (!atNextPart ? parts[parts.size() - 1].size() : 0) + 1; - newText += suggestion.main.title.sub(suggestionStartIndex); + newText += currentSuggestion.main.title.sub(suggestionStartIndex); } setSuggestionText(newText); - }, [suggestion]); + }, [currentSuggestion]); useEventListener(UserInputService.InputBegan, (input) => { if (ref.current === undefined) return; @@ -151,7 +152,8 @@ export function TerminalTextField({ if (input.KeyCode !== Enum.KeyCode.Tab) return; - const commandPath = store.getState().app.command; + const state = store.getState(); + const commandPath = state.command.path; const suggestionTextValue = getBindingValue(suggestionText); // Handle command suggestions @@ -181,21 +183,21 @@ export function TerminalTextField({ // Handle argument suggestions if ( commandPath === undefined || - suggestion === undefined || - suggestion.others.isEmpty() + currentSuggestion === undefined || + currentSuggestion.others.isEmpty() ) return; - const argIndex = store.getState().app.argIndex; + const argIndex = state.command.argIndex; const commandArgs = data.commands.get(commandPath.toString())?.arguments; if (argIndex === undefined || commandArgs === undefined) return; let newText = getBindingValue(text); if (!endsWithSpace(newText)) { - const parts = store.getState().app.text.parts; + const parts = state.text.parts; newText = newText.sub(0, newText.size() - parts[parts.size() - 1].size()); } - newText += suggestion.others[0]; + newText += currentSuggestion.others[0]; if (argIndex < commandArgs.size() - 1) { newText += " "; diff --git a/src/client/interface/components/terminal/TerminalWindow.tsx b/src/client/interface/components/terminal/TerminalWindow.tsx index dbf27e72..7e5dc22c 100644 --- a/src/client/interface/components/terminal/TerminalWindow.tsx +++ b/src/client/interface/components/terminal/TerminalWindow.tsx @@ -14,8 +14,11 @@ import { useMotion } from "../../hooks/useMotion"; import { useRem } from "../../hooks/useRem"; import { useStore } from "../../hooks/useStore"; import { CommanderContext } from "../../providers/commanderProvider"; -import { SuggestionContext } from "../../providers/suggestionProvider"; -import { HistoryLineData } from "../../types"; +import { HistoryLineData, Suggestion } from "../../types"; +import { + getArgumentSuggestion, + getCommandSuggestion, +} from "../../util/suggestion"; import { Frame } from "../interface/Frame"; import { Padding } from "../interface/Padding"; import { Shadow } from "../interface/Shadow"; @@ -34,7 +37,6 @@ export function TerminalWindow() { const rem = useRem(); const store = useStore(); const data = useContext(CommanderContext); - const suggestionData = useContext(SuggestionContext); const [historyData, setHistoryData] = useState({ lines: [], @@ -55,15 +57,14 @@ export function TerminalWindow() { }); }, [rem]); - const checkArgs = useLatestCallback( + const checkMissingArgs = useLatestCallback( (path: ImmutableCommandPath, command: CommandOptions) => { if (command.arguments === undefined || command.arguments.isEmpty()) { return undefined; } const storeState = store.getState(); - const lastPartIndex = - storeState.app.text.parts.size() - path.getSize() - 1; + const lastPartIndex = storeState.text.parts.size() - path.getSize() - 1; const missingArgs: string[] = []; let index = 0; @@ -135,12 +136,13 @@ export function TerminalWindow() { store.setText(text, parts); if (parts.isEmpty()) { - suggestionData.updateSuggestion(); + store.clearSuggestions(); store.setArgIndex(undefined); return; } - let parentPath = store.getState().app.command; + store.flush(); + let parentPath = store.getState().command.path; let atCommand = data.commands.has(formatPartsAsPath(parts)); // If the text ends in a space, we want to count that as having traversed @@ -182,20 +184,23 @@ export function TerminalWindow() { } } else { parentPath = getParentPath(parts, atNextPart); - if (atCommand) + if (atCommand) { store.setCommand(new ImmutableCommandPath(copy(parts))); + } } if (atCommand) { - const commandPath = store.getState().app.command; + store.flush(); + const commandPath = store.getState().command.path; const command = commandPath !== undefined ? data.commands.get(commandPath.toString()) : undefined; if (commandPath !== undefined && command !== undefined) { - const argCheckMessage = checkArgs(commandPath, command); - store.setErrorText(argCheckMessage); + store.setTextValid( + checkMissingArgs(commandPath, command) === undefined, + ); } } else { store.setCommand(undefined); @@ -211,32 +216,31 @@ export function TerminalWindow() { ? parts[parts.size() - 1] : undefined; + let suggestion: Suggestion | undefined; if (argCount === 0) { - suggestionData.updateSuggestion({ - type: "command", + // Handle command suggestions + suggestion = getCommandSuggestion(parentPath, currentTextPart); + } else if (parentPath !== undefined) { + // Handle argument suggestions + const argIndex = + parts.size() - parentPath.getSize() - (atNextPart ? 0 : 1); + if (argIndex >= argCount) return; + + store.setArgIndex(argIndex); + suggestion = getArgumentSuggestion( parentPath, - text: currentTextPart, - }); - return; + argIndex, + currentTextPart, + ); } - if (parentPath === undefined) return; - - const argIndex = - parts.size() - parentPath.getSize() - (atNextPart ? 0 : 1); - if (argIndex >= argCount) return; - - store.setArgIndex(argIndex); - suggestionData.updateSuggestion({ - type: "argument", - commandPath: parentPath, - index: argIndex, - text: currentTextPart, - }); + const suggestionIndex = parts.size() - 1 + (atNextPart ? 1 : 0); + store.setSuggestion(suggestionIndex, suggestion); + store.setSuggestionIndex(suggestionIndex); }} onSubmit={(text) => { const storeState = store.getState(); - const commandPath = storeState.app.command; + const commandPath = storeState.command.path; const command = commandPath !== undefined ? data.commands.get(commandPath.toString()) @@ -250,7 +254,7 @@ export function TerminalWindow() { return; } - const argCheckMessage = checkArgs(commandPath, command); + const argCheckMessage = checkMissingArgs(commandPath, command); if (argCheckMessage !== undefined) { data.addHistoryEntry({ success: false, 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..caee8be1 100644 --- a/src/client/interface/components/terminal/suggestion/Suggestions.tsx +++ b/src/client/interface/components/terminal/suggestion/Suggestions.tsx @@ -1,26 +1,20 @@ -import { getBindingValue } from "@rbxts/pretty-react-hooks"; import { useSelector } from "@rbxts/react-reflex"; -import Roact, { - useBinding, - useContext, - useEffect, - useMemo, -} from "@rbxts/roact"; +import Roact, { useBinding, useEffect, 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"; import { useRem } from "../../../hooks/useRem"; -import { SuggestionContext } from "../../../providers/suggestionProvider"; -import { selectText } from "../../../store/app"; +import { selectCurrentSuggestion } from "../../../store/suggestion"; +import { selectText } from "../../../store/text"; import { Frame } from "../../interface/Frame"; 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; @@ -46,10 +40,11 @@ export function SuggestionList({ position }: SuggestionListProps) { ); // Suggestions - const suggestion = useContext(SuggestionContext).suggestion; - const [sizes, setSizes] = useBinding({ + const currentSuggestion = useSelector(selectCurrentSuggestion); + const [sizes, setSizes] = useBinding({ title: UDim2.fromOffset(rem(16), rem(2)), description: UDim2.fromOffset(rem(16), rem(2)), + errorTextHeight: 0, typeBadgeWidth: rem(6), }); @@ -60,60 +55,47 @@ export function SuggestionList({ position }: SuggestionListProps) { // Resize window based on suggestions useEffect(() => { - if (suggestion === undefined) { + if (currentSuggestion === undefined) { suggestionSizeMotion.spring(new UDim2()); otherSuggestionSizeMotion.spring(new UDim2()); return; } - const mainSuggestion = suggestion.main; - const otherSuggestions = suggestion.others; + const mainSuggestion = currentSuggestion.main; + const otherSuggestions = currentSuggestion.others; if (otherSuggestions.isEmpty()) { 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()) { @@ -147,19 +129,19 @@ export function SuggestionList({ position }: SuggestionListProps) { UDim2.fromOffset(windowWidth, windowHeight), springs.responsive, ); - }, [suggestion, rem]); + }, [currentSuggestion, rem]); return ( - {suggestion?.others?.map((name, i) => { + {currentSuggestion?.others?.map((name, i) => { return ( `; +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/providers/rootProvider.tsx b/src/client/interface/providers/rootProvider.tsx index 9fb5fde6..b9b39f4d 100644 --- a/src/client/interface/providers/rootProvider.tsx +++ b/src/client/interface/providers/rootProvider.tsx @@ -3,32 +3,19 @@ import Roact from "@rbxts/roact"; import { store } from "../store"; import { CommanderProvider, CommanderProviderProps } from "./commanderProvider"; import { RemProvider, RemProviderProps } from "./remProvider"; -import { - SuggestionProvider, - SuggestionProviderProps, -} from "./suggestionProvider"; -interface RootProviderProps - extends RemProviderProps, - CommanderProviderProps, - SuggestionProviderProps {} +interface RootProviderProps extends RemProviderProps, CommanderProviderProps {} export function RootProvider({ baseRem, value: data, - getSuggestion, children, }: RootProviderProps) { return ( - - {children} - + {children} diff --git a/src/client/interface/providers/suggestionProvider.tsx b/src/client/interface/providers/suggestionProvider.tsx deleted file mode 100644 index 94feda98..00000000 --- a/src/client/interface/providers/suggestionProvider.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import Roact, { createContext, useCallback, useState } from "@rbxts/roact"; -import { Suggestion, SuggestionQuery } from "../types"; - -export interface SuggestionContextData { - suggestion?: Suggestion; - updateSuggestion: (query?: SuggestionQuery) => void; -} - -export interface SuggestionProviderProps extends Roact.PropsWithChildren { - getSuggestion: (query: SuggestionQuery) => Suggestion | undefined; -} - -export const DEFAULT_SUGGESTION_CONTEXT: SuggestionContextData = { - updateSuggestion: () => {}, -}; - -export const SuggestionContext = createContext(DEFAULT_SUGGESTION_CONTEXT); - -export function SuggestionProvider({ - getSuggestion, - children, -}: SuggestionProviderProps) { - const [suggestion, setSuggestion] = useState(); - - const updateSuggestion = useCallback((query?: SuggestionQuery) => { - setSuggestion(query !== undefined ? getSuggestion(query) : undefined); - }, []); - - return ( - - {children} - - ); -} diff --git a/src/client/interface/store/app/appSelectors.ts b/src/client/interface/store/app/appSelectors.ts index 6074d23b..86728b53 100644 --- a/src/client/interface/store/app/appSelectors.ts +++ b/src/client/interface/store/app/appSelectors.ts @@ -1,9 +1,3 @@ import { RootState } from ".."; 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; diff --git a/src/client/interface/store/app/appSlice.ts b/src/client/interface/store/app/appSlice.ts index 90578d7f..3e55b214 100644 --- a/src/client/interface/store/app/appSlice.ts +++ b/src/client/interface/store/app/appSlice.ts @@ -1,95 +1,13 @@ import { createProducer } from "@rbxts/reflex"; -import { copy } from "@rbxts/sift/out/Array"; -import { ImmutableCommandPath } from "../../../../shared"; -import { HistoryEntry } from "../../../types"; export interface AppState { visible: boolean; - - commandHistory: string[]; - commandHistoryIndex: number; - - command?: ImmutableCommandPath; - argIndex?: number; - text: { - value: string; - parts: string[]; - index: number; - }; - errorText?: string; } export const initialAppState: AppState = { visible: false, - commandHistory: [], - commandHistoryIndex: -1, - text: { - value: "", - parts: [], - index: -1, - }, }; -/** - * Limits an array by removing the first n (limit) elments if - * the array's size exceeds the limit. - * - * @param array The array to limit - * @param limit The limit - */ -function limitArray(array: T[], limit: number) { - if (array.size() <= limit) return; - for (const i of $range(0, math.min(array.size() - 1, limit - 1))) { - array.remove(i); - } -} - export const appSlice = createProducer(initialAppState, { setVisible: (state, visible: boolean) => ({ ...state, visible }), - - addCommandHistory: (state, command: string, limit: number) => { - const commandHistory = copy(state.commandHistory); - limitArray(commandHistory, limit); - commandHistory.push(command); - - return { - ...state, - commandHistory, - }; - }, - - setCommandHistoryIndex: (state, index: number) => ({ - ...state, - commandHistoryIndex: index, - }), - - setHistory: (state, history: HistoryEntry[]) => ({ - ...state, - history, - }), - - setCommand: (state, path?: ImmutableCommandPath) => ({ - ...state, - command: path, - }), - - setArgIndex: (state, index?: number) => ({ ...state, argIndex: index }), - - setText: (state, text: string, textParts: string[]) => { - const endsWithSpace = textParts.size() > 0 && text.match("%s$").size() > 0; - - return { - ...state, - text: { - value: text, - parts: textParts, - index: endsWithSpace ? textParts.size() : textParts.size() - 1, - }, - }; - }, - - setErrorText: (state, text?: string) => ({ - ...state, - errorText: text, - }), }); diff --git a/src/client/interface/store/command/commandSelectors.ts b/src/client/interface/store/command/commandSelectors.ts new file mode 100644 index 00000000..54c7c4ae --- /dev/null +++ b/src/client/interface/store/command/commandSelectors.ts @@ -0,0 +1,3 @@ +import { RootState } from ".."; + +export const selectCommand = (state: RootState) => state.command.path; diff --git a/src/client/interface/store/command/commandSlice.ts b/src/client/interface/store/command/commandSlice.ts new file mode 100644 index 00000000..70a1dfb6 --- /dev/null +++ b/src/client/interface/store/command/commandSlice.ts @@ -0,0 +1,18 @@ +import { createProducer } from "@rbxts/reflex"; +import { ImmutableCommandPath } from "../../../../shared"; + +export interface CommandState { + path?: ImmutableCommandPath; + argIndex?: number; +} + +export const initialCommandState: CommandState = {}; + +export const commandSlice = createProducer(initialCommandState, { + setCommand: (state, path?: ImmutableCommandPath) => ({ + ...state, + path, + }), + + setArgIndex: (state, index?: number) => ({ ...state, argIndex: index }), +}); diff --git a/src/client/interface/store/command/index.ts b/src/client/interface/store/command/index.ts new file mode 100644 index 00000000..054aa8c3 --- /dev/null +++ b/src/client/interface/store/command/index.ts @@ -0,0 +1,2 @@ +export * from "./commandSelectors"; +export * from "./commandSlice"; diff --git a/src/client/interface/store/history/historySlice.ts b/src/client/interface/store/history/historySlice.ts new file mode 100644 index 00000000..d84d9b18 --- /dev/null +++ b/src/client/interface/store/history/historySlice.ts @@ -0,0 +1,44 @@ +import { createProducer } from "@rbxts/reflex"; +import { copy } from "@rbxts/sift/out/Array"; + +export interface HistoryState { + commandHistory: string[]; + commandHistoryIndex: number; +} + +export const initialHistoryState: HistoryState = { + commandHistory: [], + commandHistoryIndex: -1, +}; + +/** + * Limits an array by removing the first n (limit) elements if + * the array's size exceeds the limit. + * + * @param array The array to limit + * @param limit The limit + */ +function limitArray(array: T[], limit: number) { + if (array.size() <= limit) return; + for (const i of $range(0, math.min(array.size() - 1, limit - 1))) { + array.remove(i); + } +} + +export const historySlice = createProducer(initialHistoryState, { + addCommandHistory: (state, command: string, limit: number) => { + const commandHistory = copy(state.commandHistory); + limitArray(commandHistory, limit); + commandHistory.push(command); + + return { + ...state, + commandHistory, + }; + }, + + setCommandHistoryIndex: (state, index: number) => ({ + ...state, + commandHistoryIndex: index, + }), +}); diff --git a/src/client/interface/store/history/index.ts b/src/client/interface/store/history/index.ts new file mode 100644 index 00000000..3f283e94 --- /dev/null +++ b/src/client/interface/store/history/index.ts @@ -0,0 +1 @@ +export * from "./historySlice"; diff --git a/src/client/interface/store/index.ts b/src/client/interface/store/index.ts index 0076ec56..05305ba7 100644 --- a/src/client/interface/store/index.ts +++ b/src/client/interface/store/index.ts @@ -1,9 +1,17 @@ import { InferState, combineProducers } from "@rbxts/reflex"; import { appSlice } from "./app"; +import { commandSlice } from "./command"; +import { historySlice } from "./history"; +import { suggestionSlice } from "./suggestion"; +import { textSlice } from "./text"; export type RootStore = typeof store; export type RootState = InferState; export const store = combineProducers({ app: appSlice, + command: commandSlice, + history: historySlice, + suggestion: suggestionSlice, + text: textSlice, }); diff --git a/src/client/interface/store/suggestion/index.ts b/src/client/interface/store/suggestion/index.ts new file mode 100644 index 00000000..01d3cda6 --- /dev/null +++ b/src/client/interface/store/suggestion/index.ts @@ -0,0 +1,2 @@ +export * from "./suggestionSelectors"; +export * from "./suggestionSlice"; diff --git a/src/client/interface/store/suggestion/suggestionSelectors.ts b/src/client/interface/store/suggestion/suggestionSelectors.ts new file mode 100644 index 00000000..f5296225 --- /dev/null +++ b/src/client/interface/store/suggestion/suggestionSelectors.ts @@ -0,0 +1,7 @@ +import { RootState } from ".."; + +export const selectCurrentSuggestion = (state: RootState) => { + const index = state.suggestion.currentIndex; + if (index < 0 || index >= state.suggestion.value.size()) return undefined; + return state.suggestion.value[index]; +}; diff --git a/src/client/interface/store/suggestion/suggestionSlice.ts b/src/client/interface/store/suggestion/suggestionSlice.ts new file mode 100644 index 00000000..94573f3b --- /dev/null +++ b/src/client/interface/store/suggestion/suggestionSlice.ts @@ -0,0 +1,46 @@ +import { createProducer } from "@rbxts/reflex"; +import { append, removeIndex, set } from "@rbxts/sift/out/Array"; +import { Suggestion } from "../../types"; + +export interface SuggestionState { + value: Suggestion[]; + currentIndex: number; +} + +export const initialSuggestionState: SuggestionState = { + value: [], + currentIndex: -1, +}; + +export const suggestionSlice = createProducer(initialSuggestionState, { + setSuggestion: (state, index: number, suggestion?: Suggestion) => { + if (index < 0) return state; + + let suggestions: Suggestion[]; + if (index >= state.value.size()) { + if (suggestion === undefined) return state; + suggestions = append(state.value, suggestion); + } else { + suggestions = + suggestion !== undefined + ? set(state.value, index + 1, suggestion) + : removeIndex(state.value, index + 1); + } + + return { + ...state, + value: suggestions, + }; + }, + + setSuggestionIndex: (state, index: number) => ({ + ...state, + currentIndex: index, + }), + + clearSuggestions: (state) => ({ + ...state, + value: [], + currentIndex: -1, + }), +}); diff --git a/src/client/interface/store/text/index.ts b/src/client/interface/store/text/index.ts new file mode 100644 index 00000000..a958ea24 --- /dev/null +++ b/src/client/interface/store/text/index.ts @@ -0,0 +1,2 @@ +export * from "./textSelectors"; +export * from "./textSlice"; diff --git a/src/client/interface/store/text/textSelectors.ts b/src/client/interface/store/text/textSelectors.ts new file mode 100644 index 00000000..7fe7dbe6 --- /dev/null +++ b/src/client/interface/store/text/textSelectors.ts @@ -0,0 +1,5 @@ +import { RootState } from ".."; + +export const selectText = (state: RootState) => state.text; + +export const selectValid = (state: RootState) => state.text.valid; diff --git a/src/client/interface/store/text/textSlice.ts b/src/client/interface/store/text/textSlice.ts new file mode 100644 index 00000000..400f78c3 --- /dev/null +++ b/src/client/interface/store/text/textSlice.ts @@ -0,0 +1,33 @@ +import { createProducer } from "@rbxts/reflex"; + +export interface TextState { + value: string; + parts: string[]; + index: number; + valid: boolean; +} + +export const initialTextState: TextState = { + value: "", + parts: [], + index: -1, + valid: false, +}; + +export const textSlice = createProducer(initialTextState, { + setText: (state, text: string, textParts: string[]) => { + const endsWithSpace = textParts.size() > 0 && text.match("%s$").size() > 0; + + return { + ...state, + value: text, + parts: textParts, + index: endsWithSpace ? textParts.size() : textParts.size() - 1, + }; + }, + + setTextValid: (state, valid: boolean) => ({ + ...state, + valid, + }), +}); diff --git a/src/client/interface/types.ts b/src/client/interface/types.ts index 0099f07e..1741ac53 100644 --- a/src/client/interface/types.ts +++ b/src/client/interface/types.ts @@ -1,4 +1,3 @@ -import { CommandPath } from "../../shared"; import { HistoryEntry } from "../types"; export interface HistoryLineData { @@ -18,24 +17,10 @@ export interface ArgumentSuggestion { description?: string; dataType: string; optional: boolean; + error?: string; } export interface Suggestion { main: ArgumentSuggestion | CommandSuggestion; others: string[]; } - -export interface ArgumentSuggestionQuery { - type: "argument"; - commandPath: CommandPath; - text?: string; - index: number; -} - -export interface CommandSuggestionQuery { - type: "command"; - parentPath?: CommandPath; - text?: string; -} - -export type SuggestionQuery = ArgumentSuggestionQuery | CommandSuggestionQuery; diff --git a/src/client/interface/app/suggestion.ts b/src/client/interface/util/suggestion.ts similarity index 85% rename from src/client/interface/app/suggestion.ts rename to src/client/interface/util/suggestion.ts index 4a8fc337..0d953740 100644 --- a/src/client/interface/app/suggestion.ts +++ b/src/client/interface/util/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/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,