diff --git a/packages/ui/src/app/index.ts b/packages/ui/src/app/index.ts index 5f1fdc1..8139e1b 100644 --- a/packages/ui/src/app/index.ts +++ b/packages/ui/src/app/index.ts @@ -9,9 +9,6 @@ import { InterfaceOptions } from "../types"; import { CenturionApp } from "./centurion-app"; export namespace CenturionUI { - const MAX_PRELOAD_ATTEMPTS = 3; - const PRELOAD_ATTEMPT_INTERVAL = 3; - /** * Returns whether the terminal UI is visible. * @@ -61,26 +58,6 @@ export namespace CenturionUI { } updateOptions(options); - // Attempt to preload font - task.spawn(() => { - const fontFamily = ( - options.font?.regular ?? DEFAULT_INTERFACE_OPTIONS.font.regular - ).Family; - - let attempts = 0; - while (attempts < MAX_PRELOAD_ATTEMPTS) { - ContentProvider.PreloadAsync([fontFamily], (_, status) => { - if (status === Enum.AssetFetchStatus.Success) { - attempts = MAX_PRELOAD_ATTEMPTS; - } - }); - - if (attempts === MAX_PRELOAD_ATTEMPTS) break; - task.wait(PRELOAD_ATTEMPT_INTERVAL); - attempts++; - } - }); - mount( () => CenturionApp(client), Players.LocalPlayer.WaitForChild("PlayerGui"), diff --git a/packages/ui/src/components/history/history-line.tsx b/packages/ui/src/components/history/history-line.tsx index 0003d25..5aac6f8 100644 --- a/packages/ui/src/components/history/history-line.tsx +++ b/packages/ui/src/components/history/history-line.tsx @@ -11,12 +11,11 @@ import { TextField } from "../ui/text-field"; interface HistoryLineProps { data: HistoryEntry; - size?: Derivable; position?: Derivable; order?: Derivable; } -export function HistoryLine({ data, size, position, order }: HistoryLineProps) { +export function HistoryLine({ data, position, order }: HistoryLineProps) { const date = derive(() => { const dateTime = DateTime.fromUnixTimestamp(data.sentAt).FormatLocalTime( "LT", @@ -27,7 +26,12 @@ export function HistoryLine({ data, size, position, order }: HistoryLineProps) { }); return ( - + options().palette.surface} size={() => UDim2.fromOffset(px(76), px(HISTORY_TEXT_SIZE + 4))} @@ -55,9 +59,10 @@ export function HistoryLine({ data, size, position, order }: HistoryLineProps) { new UDim2(1, -px(84), 1, 0)} - position={UDim2.fromScale(1, 0)} + size={() => new UDim2(1, -px(84), 0, 0)} + position={() => new UDim2(1, 0, 0, px(2))} text={data.text} textSize={() => px(HISTORY_TEXT_SIZE)} textColor={() => { diff --git a/packages/ui/src/components/history/history-list.tsx b/packages/ui/src/components/history/history-list.tsx index 6f0e462..e8487be 100644 --- a/packages/ui/src/components/history/history-list.tsx +++ b/packages/ui/src/components/history/history-list.tsx @@ -1,4 +1,13 @@ -import Vide, { Derivable, derive, For, read } from "@rbxts/vide"; +import { HistoryEntry } from "@rbxts/centurion"; +import Vide, { + Derivable, + derive, + effect, + For, + read, + source, +} from "@rbxts/vide"; +import { HISTORY_TEXT_SIZE } from "../../constants/text"; import { px } from "../../hooks/use-px"; import { options } from "../../store"; import { HistoryData, HistoryLineData } from "../../types"; @@ -6,49 +15,51 @@ import { ScrollingFrame } from "../ui/scrolling-frame"; import { HistoryLine } from "./history-line"; interface HistoryListProps { - data: Derivable; + entries: Derivable; size?: Derivable; position?: Derivable; - maxHeight?: Derivable; scrollingEnabled?: Derivable; + onContentSizeChanged?: (size: Vector2) => void; } export function HistoryList({ - data, + entries, size, position, - maxHeight, + scrollingEnabled, + onContentSizeChanged, }: HistoryListProps) { - const height = derive(() => read(data).height - px(8)); - const exceedsMaxHeight = derive( - () => maxHeight !== undefined && height() > read(maxHeight), - ); + const ref = source(); return ( UDim2.fromOffset(0, height())} - canvasPosition={() => new Vector2(0, height())} + canvasSize={new UDim2()} + action={ref} scrollBarColor={() => options().palette.subtext} - scrollBarThickness={() => (exceedsMaxHeight() ? 10 : 0)} - scrollingEnabled={() => exceedsMaxHeight()} + scrollingEnabled={scrollingEnabled} + scrollBarThickness={() => (read(scrollingEnabled) ? 10 : 0)} + scrollingDirection="Y" + native={{ + AbsoluteCanvasSizeChanged: (rbx) => { + const frame = ref(); + if (frame === undefined) return; + frame.CanvasPosition = new Vector2(0, rbx.Y); + }, + }} > - read(data).lines}> - {(line: HistoryLineData, index: () => number) => { - return ( - - ); + read(entries)}> + {(entry: HistoryEntry, index: () => number) => { + return ; }} new UDim(0, px(8))} SortOrder="LayoutOrder" + AbsoluteContentSizeChanged={(rbx) => onContentSizeChanged?.(rbx)} /> ); diff --git a/packages/ui/src/components/suggestions/main-suggestion.tsx b/packages/ui/src/components/suggestions/main-suggestion.tsx index f41020e..1e1700d 100644 --- a/packages/ui/src/components/suggestions/main-suggestion.tsx +++ b/packages/ui/src/components/suggestions/main-suggestion.tsx @@ -1,4 +1,4 @@ -import Vide, { Derivable, read, spring } from "@rbxts/vide"; +import Vide, { Derivable, derive, read, source, spring } from "@rbxts/vide"; import { SUGGESTION_TEXT_SIZE, SUGGESTION_TITLE_TEXT_SIZE, @@ -9,34 +9,58 @@ import { Suggestion } from "../../types"; import { Frame } from "../ui/frame"; import { Padding } from "../ui/padding"; import { Text } from "../ui/text"; -import { Badge } from "./badge"; import { highlightMatching } from "./util"; export interface MainSuggestionProps { suggestion: Derivable; currentText?: Derivable; - size: Derivable; - titleSize: Derivable; - descriptionSize: Derivable; - badgeSize: Derivable; - errorSize: Derivable; action?: (instance: Frame) => void; + onSizeChanged?: (size: UDim2) => void; } +const MAX_WIDTH = 180; + export function MainSuggestion({ suggestion, currentText, - size, - titleSize, - descriptionSize, - badgeSize, - errorSize, action, + onSizeChanged, }: MainSuggestionProps) { + const titleBounds = source(new Vector2()); + const descriptionBounds = source(new Vector2()); + const errorBounds = source(new Vector2()); + const badgeBounds = source(new Vector2()); + const errorText = derive(() => { + const currentSuggestion = read(suggestion); + return currentSuggestion?.type === "argument" + ? currentSuggestion.error + : undefined; + }); + + const windowSize = derive(() => { + if (read(suggestion) === undefined) { + const size = new UDim2(); + onSizeChanged?.(size); + return size; + } + + const titleSize = read(titleBounds); + const descriptionSize = read(descriptionBounds); + const errorSize = read(errorBounds); + + const width = + math.max(titleSize.X, descriptionSize.X, errorSize.X) + badgeBounds().X; + const height = titleSize.Y + descriptionSize.Y + errorSize.Y; + + const size = UDim2.fromOffset(width + px(8) * 2, height + px(8) * 2); + onSizeChanged?.(size); + return size; + }); + return ( options().palette.background} backgroundTransparency={() => options().backgroundTransparency ?? 0} cornerRadius={() => new UDim(0, px(8))} @@ -47,34 +71,6 @@ export function MainSuggestion({ > new UDim(0, px(8))} /> - options().palette.highlight} - text={() => { - const currentSuggestion = read(suggestion); - return currentSuggestion !== undefined && - currentSuggestion.type === "argument" - ? currentSuggestion.dataType - : ""; - }} - textColor={() => options().palette.surface} - textSize={() => px(SUGGESTION_TEXT_SIZE)} - visible={() => { - const currentSuggestion = read(suggestion); - return ( - currentSuggestion !== undefined && - currentSuggestion.type === "argument" - ); - }} - anchor={new Vector2(1, 0)} - position={UDim2.fromScale(1, 0)} - size={spring(() => { - return UDim2.fromOffset( - read(badgeSize).X.Offset + px(4), - px(SUGGESTION_TITLE_TEXT_SIZE), - ); - }, 0.2)} - /> - { const currentSuggestion = read(suggestion); @@ -92,7 +88,12 @@ export function MainSuggestion({ textYAlignment="Top" font={() => options().font.bold} richText - size={titleSize} + size={() => + UDim2.fromOffset(titleBounds().X, px(SUGGESTION_TITLE_TEXT_SIZE)) + } + native={{ + TextBoundsChanged: (bounds) => titleBounds(bounds), + }} /> UDim2.fromOffset(0, px(SUGGESTION_TITLE_TEXT_SIZE))} - size={descriptionSize} + size={() => + UDim2.fromOffset( + px(MAX_WIDTH), + math.max(descriptionBounds().Y, px(SUGGESTION_TEXT_SIZE)), + ) + } + native={{ + TextBoundsChanged: (rbx) => descriptionBounds(rbx), + }} /> { - const currentSuggestion = read(suggestion); - return currentSuggestion !== undefined && - currentSuggestion.type === "argument" - ? (currentSuggestion.error ?? "") - : ""; - }} + text={() => errorText() ?? ""} textColor={() => options().palette.error} textSize={() => px(SUGGESTION_TEXT_SIZE)} textTransparency={spring(() => { - const currentSuggestion = read(suggestion); - return currentSuggestion?.type === "argument" && - currentSuggestion.error !== undefined - ? 0 - : 1; + return errorText() !== undefined ? 0 : 1; }, 0.2)} textXAlignment="Left" + textWrapped + automaticSize="Y" anchor={new Vector2(0, 1)} position={UDim2.fromScale(0, 1)} - size={errorSize} + size={() => UDim2.fromOffset(px(MAX_WIDTH), 0)} + native={{ + TextBoundsChanged: (bounds) => { + errorBounds(errorText() !== undefined ? bounds : Vector2.zero); + }, + }} /> + + options().palette.highlight} + cornerRadius={() => new UDim(0, px(4))} + clipsDescendants + visible={() => { + const currentSuggestion = read(suggestion); + return ( + currentSuggestion !== undefined && + currentSuggestion.type === "argument" + ); + }} + anchor={new Vector2(1, 0)} + position={UDim2.fromScale(1, 0)} + size={spring(() => { + return UDim2.fromOffset( + badgeBounds().X + px(4), + px(SUGGESTION_TITLE_TEXT_SIZE), + ); + }, 0.2)} + > + { + const currentSuggestion = read(suggestion); + return currentSuggestion !== undefined && + currentSuggestion.type === "argument" + ? currentSuggestion.dataType + : ""; + }} + textColor={() => options().palette.surface} + textSize={() => px(SUGGESTION_TEXT_SIZE)} + textXAlignment="Center" + font={() => options().font.bold} + size={UDim2.fromScale(1, 1)} + native={{ + TextBoundsChanged: (bounds) => badgeBounds(bounds), + }} + /> + ); } diff --git a/packages/ui/src/components/suggestions/suggestions.tsx b/packages/ui/src/components/suggestions/suggestions.tsx index 0d658a9..5dbfaec 100644 --- a/packages/ui/src/components/suggestions/suggestions.tsx +++ b/packages/ui/src/components/suggestions/suggestions.tsx @@ -1,59 +1,16 @@ import { TextService } from "@rbxts/services"; import Vide, { cleanup, derive, source, spring } from "@rbxts/vide"; -import { - SUGGESTION_TEXT_SIZE, - SUGGESTION_TITLE_TEXT_SIZE, -} from "../../constants/text"; +import { SUGGESTION_TEXT_SIZE } from "../../constants/text"; import { px } from "../../hooks/use-px"; -import { useTextBounds } from "../../hooks/use-text-bounds"; import { currentSuggestion, currentTextPart, options } from "../../store"; import { Group } from "../ui/group"; import { MainSuggestion } from "./main-suggestion"; import { SuggestionList } from "./suggestion-list"; -const MAX_SUGGESTION_WIDTH = 180; const PADDING = 8; export function Suggestions() { - const suggestionRef = source(); - - const offset = (boundsState: () => Vector2) => () => { - const bounds = boundsState(); - return new UDim2(0, bounds.X, 0, bounds.Y); - }; - - const titleBounds = useTextBounds({ - text: () => currentSuggestion()?.title, - font: () => options().font.bold, - size: () => px(SUGGESTION_TITLE_TEXT_SIZE), - }); - - const descriptionBounds = useTextBounds({ - text: () => currentSuggestion()?.description, - font: () => options().font.regular, - size: () => px(SUGGESTION_TEXT_SIZE), - width: () => px(MAX_SUGGESTION_WIDTH), - }); - - const typeBadgeBounds = useTextBounds({ - text: () => { - const suggestion = currentSuggestion(); - if (suggestion?.type === "command") return; - return suggestion?.dataType; - }, - font: () => options().font.bold, - size: () => px(SUGGESTION_TEXT_SIZE), - }); - - const errorBounds = useTextBounds({ - text: () => { - const suggestion = currentSuggestion(); - if (suggestion?.type === "command") return; - return suggestion?.error; - }, - font: () => options().font.regular, - size: () => px(SUGGESTION_TEXT_SIZE), - }); + const mainSize = source(new UDim2()); const listBoundsParams = new Instance("GetTextBoundsParams"); listBoundsParams.RichText = true; @@ -77,43 +34,25 @@ export function Suggestions() { return new Vector2(width, height); }); - const windowSize = derive(() => { - const width = - math.max( - titleBounds().X, - descriptionBounds().X, - errorBounds().X, - listBounds().X, - ) + typeBadgeBounds().X; - - const height = titleBounds().Y + descriptionBounds().Y + errorBounds().Y; - - const padding = px(PADDING * 2); - if (width === 0 || height === 0) { - return UDim2.fromOffset(0, suggestionRef()?.AbsoluteSize.Y ?? 0); - } - - return UDim2.fromOffset(width + padding, height + padding); - }); - return ( - + UDim2.fromOffset(windowSize().X.Offset, listBounds().Y), + () => + new UDim2( + 0, + math.max(listBounds().X, mainSize().X.Offset), + 0, + listBounds().Y, + ), 0.3, )} /> diff --git a/packages/ui/src/components/terminal/terminal.tsx b/packages/ui/src/components/terminal/terminal.tsx index 557178a..28d9c74 100644 --- a/packages/ui/src/components/terminal/terminal.tsx +++ b/packages/ui/src/components/terminal/terminal.tsx @@ -1,10 +1,10 @@ -import { ArgumentOptions, RegistryPath } from "@rbxts/centurion"; +import { ArgumentOptions, HistoryEntry, RegistryPath } from "@rbxts/centurion"; import { ArrayUtil, ReadonlyDeep } from "@rbxts/centurion/out/shared/util/data"; import { splitString } from "@rbxts/centurion/out/shared/util/string"; -import Vide, { derive, source, spring } from "@rbxts/vide"; +import Vide, { derive, effect, source, spring } from "@rbxts/vide"; import { HISTORY_TEXT_SIZE } from "../../constants/text"; import { useClient } from "../../hooks/use-client"; -import { useHistory } from "../../hooks/use-history"; +import { useEvent } from "../../hooks/use-event"; import { px } from "../../hooks/use-px"; import { commandArgIndex, @@ -33,7 +33,15 @@ const TRAILING_SPACE_PATTERN = "(%s+)$"; export function Terminal() { const client = useClient(); const missingArgs = source([]); - const history = useHistory(); + const history = source(client.dispatcher.getHistory()); + const historyHeight = source(0); + + useEvent(client.dispatcher.historyUpdated, (entries) => + history([...entries]), + ); + + const maxHeight = derive(() => px(MAX_HEIGHT)); + const scrollingEnabled = derive(() => historyHeight() > maxHeight()); const terminalTextParts = derive(() => { return splitString(terminalText(), " ", true); @@ -52,12 +60,10 @@ export function Terminal() { const terminalHeight = derive(() => { const padding = px.ceil(TEXT_FIELD_HEIGHT + 16); - if (history().lines.isEmpty()) return padding; + if (history().isEmpty()) return padding; - const totalHeight = history().height; - const isClamped = totalHeight > px(MAX_HEIGHT); - const clampedHeight = isClamped ? px(MAX_HEIGHT) : totalHeight; - return math.ceil(padding + clampedHeight); + const clampedHeight = scrollingEnabled() ? maxHeight() : historyHeight(); + return math.ceil(padding + clampedHeight + px(8)); }); return ( @@ -75,8 +81,11 @@ export function Terminal() { new UDim2(1, 0, 1, -px(TEXT_FIELD_HEIGHT + 8))} - data={history} - maxHeight={() => px(MAX_HEIGHT)} + entries={history} + scrollingEnabled={scrollingEnabled} + onContentSizeChanged={(size) => { + historyHeight(size.Y); + }} /> {props.cornerRadius && } diff --git a/packages/ui/src/components/ui/text-field.tsx b/packages/ui/src/components/ui/text-field.tsx index b98c973..51f4a04 100644 --- a/packages/ui/src/components/ui/text-field.tsx +++ b/packages/ui/src/components/ui/text-field.tsx @@ -33,7 +33,7 @@ export function TextField(props: TextFieldProps) { TextYAlignment={props.textYAlignment} TextScaled={props.textScaled} RichText={props.richText} - AutomaticSize={props.textAutoResize} + AutomaticSize={props.automaticSize} AutoLocalize={() => options().autoLocalize} Size={props.size} Position={props.position} diff --git a/packages/ui/src/components/ui/text.tsx b/packages/ui/src/components/ui/text.tsx index 033c4a2..06b56d8 100644 --- a/packages/ui/src/components/ui/text.tsx +++ b/packages/ui/src/components/ui/text.tsx @@ -16,7 +16,6 @@ export interface TextProps textTruncate?: InferEnumNames; textScaled?: Derivable; textHeight?: Derivable; - textAutoResize?: "X" | "Y" | "XY"; richText?: Derivable; } @@ -36,7 +35,7 @@ export function Text(props: TextProps) { LineHeight={props.textHeight} RichText={props.richText} Size={props.size} - AutomaticSize={props.textAutoResize} + AutomaticSize={props.automaticSize} AutoLocalize={() => options().autoLocalize} Position={props.position} AnchorPoint={props.anchor} diff --git a/packages/ui/src/hooks/use-history.ts b/packages/ui/src/hooks/use-history.ts deleted file mode 100644 index f7b34ad..0000000 --- a/packages/ui/src/hooks/use-history.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { HistoryEntry } from "@rbxts/centurion"; -import { TextService } from "@rbxts/services"; -import { cleanup, derive, source } from "@rbxts/vide"; -import { HISTORY_TEXT_SIZE } from "../constants/text"; -import { options } from "../store"; -import { HistoryData, HistoryLineData } from "../types"; -import { useClient } from "./use-client"; -import { useEvent } from "./use-event"; -import { px } from "./use-px"; - -export function useHistory() { - const client = useClient(); - const history = source(client.dispatcher.getHistory()); - - useEvent(client.dispatcher.historyUpdated, (entries) => - history([...entries]), - ); - - const textBoundsParams = new Instance("GetTextBoundsParams"); - textBoundsParams.Width = math.huge; - textBoundsParams.RichText = true; - cleanup(() => { - textBoundsParams.Destroy(); - }); - - return derive(() => { - const entries = history(); - const historySize = entries.size(); - let totalHeight = historySize > 0 ? px(8) + (historySize - 1) * px(8) : 0; - - textBoundsParams.Size = HISTORY_TEXT_SIZE; - textBoundsParams.Font = options().font.regular; - - const historyLines: HistoryLineData[] = []; - for (const entry of entries) { - textBoundsParams.Text = entry.text; - const textSize = TextService.GetTextBoundsAsync(textBoundsParams); - const lineHeight = px(textSize.Y + 4); - totalHeight += lineHeight; - historyLines.push({ entry, height: lineHeight }); - } - return { - lines: historyLines, - height: totalHeight, - }; - }); -} diff --git a/packages/ui/src/hooks/use-text-bounds.ts b/packages/ui/src/hooks/use-text-bounds.ts deleted file mode 100644 index 5decf78..0000000 --- a/packages/ui/src/hooks/use-text-bounds.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { TextService } from "@rbxts/services"; -import { Derivable, cleanup, effect, read, source } from "@rbxts/vide"; - -interface TextBoundsProps { - text: Derivable; - font?: Derivable; - size?: Derivable; - width?: Derivable; -} - -const getBounds = Promise.promisify((params: GetTextBoundsParams) => - TextService.GetTextBoundsAsync(params), -); - -export function useTextBounds({ text, font, size, width }: TextBoundsProps) { - const bounds = source(Vector2.zero); - - const params = new Instance("GetTextBoundsParams"); - params.RichText = true; - cleanup(params); - - effect(() => { - const textValue = read(text); - if (textValue === undefined) { - bounds(Vector2.zero); - return; - } - - params.Text = textValue; - - const fontValue = read(font); - const sizeValue = read(size); - const widthValue = read(width); - if (fontValue !== undefined) { - params.Font = fontValue; - } - - if (sizeValue !== undefined) { - params.Size = sizeValue; - } - - if (widthValue !== undefined) { - params.Width = widthValue; - } - - getBounds(params) - .then((value) => bounds(value)) - .catch(() => bounds(Vector2.zero)); - }); - - return bounds as () => Vector2; -}