summaryrefslogtreecommitdiff
path: root/src/components/ConfigurationTab.tsx
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-12 21:29:08 -0400
committerGitHub <noreply@github.com>2026-09-12 21:29:08 -0400
commite580c6bd60c92af36f92d4c29b5d9a44f73d47d7 (patch)
tree7fc6799626519e5b01fb137f5440c99fda5faca7 /src/components/ConfigurationTab.tsx
parente997a3fb74fa70f5e60b60807fb0897120e98313 (diff)
parent81feb03288166755545401b9df2ff2c9bc3ae7d7 (diff)
downloaddecky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.tar.gz
decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.zip
Merge pull request #264 from xXJSONDeruloXx/chore/migrate
the big one
Diffstat (limited to 'src/components/ConfigurationTab.tsx')
-rw-r--r--src/components/ConfigurationTab.tsx204
1 files changed, 204 insertions, 0 deletions
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
new file mode 100644
index 0000000..37555e0
--- /dev/null
+++ b/src/components/ConfigurationTab.tsx
@@ -0,0 +1,204 @@
+import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { FaArrowLeft } from "react-icons/fa";
+import { ConfigurationData } from "../config/configSchema";
+import type { GameTarget, KnownGameSource } from "../utils/gameTargets";
+import { sourceLabel } from "../utils/gameTargets";
+import { GameConfigurationControls } from "./GameConfigurationControls";
+import { GameConfigurationSelector } from "./GameConfigurationSelector";
+import { ProfileDetails } from "./ProfileDetails";
+
+interface ConfigurationTabProps {
+ title: string;
+ source: KnownGameSource;
+ config: ConfigurationData;
+ targets: GameTarget[];
+ runningGame: GameTarget | null;
+ onSelect: (appid: string) => void;
+ onConfigChange: (
+ fieldName: keyof ConfigurationData,
+ value: boolean | number | string | string[],
+ cleanupLaunchOptions?: boolean,
+ ) => Promise<void>;
+ onEnable: (appid: string) => Promise<boolean>;
+ onEnableAll: (source: KnownGameSource) => Promise<void>;
+ bulkOperationBusy: boolean;
+ onRepair: (appid: string) => Promise<boolean>;
+ onReset: () => Promise<void>;
+ onResetAll: (source: KnownGameSource) => Promise<void>;
+}
+
+export function ConfigurationTab({
+ title,
+ source,
+ config,
+ targets,
+ runningGame,
+ onSelect,
+ onConfigChange,
+ onEnable,
+ onEnableAll,
+ bulkOperationBusy,
+ onRepair,
+ onReset,
+ onResetAll,
+}: ConfigurationTabProps) {
+ const [detailAppId, setDetailAppId] = useState<string | null>(null);
+ const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false);
+ const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null);
+ const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false);
+ const enableRef = useRef<HTMLDivElement>(null);
+ const closeDetails = useCallback(() => {
+ setFocusFpsMultiplier(false);
+ setFocusDetailAction(null);
+ setDetailAppId(null);
+ }, []);
+ const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []);
+ const clearConfiguredToggleFocusRequest = useCallback(() => setFocusConfiguredToggle(false), []);
+
+ useEffect(() => {
+ if (!focusDetailAction) return;
+ if (focusDetailAction === "fps") {
+ setFocusFpsMultiplier(true);
+ setFocusDetailAction(null);
+ return;
+ }
+ const frame = requestAnimationFrame(() => {
+ enableRef.current?.querySelector<HTMLElement>('[role="button"]')?.focus();
+ setFocusDetailAction(null);
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [focusDetailAction]);
+
+ const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null;
+
+ if (detailAppId === null) {
+ return (
+ <>
+ <PanelSection title={title}>
+ <GameConfigurationSelector
+ targets={targets}
+ runningGame={runningGame}
+ source={source}
+ bulkOperationBusy={bulkOperationBusy}
+ onSelect={(appid) => {
+ setFocusConfiguredToggle(false);
+ setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable");
+ onSelect(appid);
+ setDetailAppId(appid);
+ }}
+ onEnableAll={onEnableAll}
+ onResetAll={onResetAll}
+ focusConfiguredToggle={focusConfiguredToggle}
+ onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
+ />
+ </PanelSection>
+ </>
+ );
+ }
+
+ const profileLabel = selectedTarget?.name || "Game profile";
+ const profileTransport = selectedTarget ? sourceLabel(selectedTarget.source) : sourceLabel(source);
+ const profileDescription = selectedTarget
+ ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}${selectedTarget.source === "unknown" ? " · Bulk actions exclude this profile" : ""}`
+ : "Game is no longer available";
+ const enableProfile = async (appid: string, quitRunningGame = false) => {
+ if (!(await onEnable(appid))) return;
+ if (quitRunningGame) SteamClient.Apps.TerminateApp(appid, false);
+ setFocusFpsMultiplier(true);
+ };
+ const handleProfileAction = async () => {
+ if (selectedTarget?.configured) {
+ await onReset();
+ setFocusConfiguredToggle(true);
+ closeDetails();
+ } else if (detailAppId) {
+ const isRunningUnconfigured = runningGame?.appid === detailAppId
+ && runningGame.source === "steam"
+ && selectedTarget?.source === "steam"
+ && !runningGame.configured;
+ if (isRunningUnconfigured) {
+ showModal(
+ <ConfirmModal
+ strTitle="Game is running"
+ strDescription="Quit the game now so LSFG-VK is used on its next launch?"
+ strOKButtonText="Quit and enable"
+ strCancelButtonText="Enable without quitting"
+ onOK={() => void enableProfile(detailAppId, true)}
+ onCancel={() => void enableProfile(detailAppId)}
+ />,
+ );
+ } else {
+ await enableProfile(detailAppId);
+ }
+ }
+ };
+
+ return (
+ <Focusable onCancelButton={closeDetails}>
+ <PanelSection>
+ <PanelSectionRow>
+ <div style={{ display: "flex", alignItems: "center", width: "100%" }}>
+ <Focusable noFocusRing style={{ flex: "none" }}>
+ <DialogButton
+ aria-label={`Back to ${title}`}
+ onClick={closeDetails}
+ style={{
+ width: "48px",
+ minWidth: "48px",
+ height: "24px",
+ minHeight: "24px",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <FaArrowLeft />
+ </DialogButton>
+ </Focusable>
+ <div
+ className={gamepadDialogClasses.FieldLabel}
+ style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
+ >
+ {profileLabel}
+ </div>
+ </div>
+ </PanelSectionRow>
+ </PanelSection>
+ <PanelSection>
+ {!selectedTarget?.configured && selectedTarget && (
+ <PanelSectionRow>
+ {selectedTarget.source === "unknown" ? (
+ <Field
+ label="Target source unavailable"
+ description="Refresh Steam and try again before enabling this target."
+ />
+ ) : (
+ <Focusable ref={enableRef} noFocusRing>
+ <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem>
+ </Focusable>
+ )}
+ </PanelSectionRow>
+ )}
+ </PanelSection>
+ {selectedTarget?.configured && (
+ <GameConfigurationControls
+ config={config}
+ onConfigChange={(field, value) => onConfigChange(field, value, selectedTarget?.source !== "unknown")}
+ autoFocusFpsMultiplier={focusFpsMultiplier}
+ onFpsMultiplierFocused={clearFpsFocusRequest}
+ showWorkarounds={selectedTarget.source !== "unknown"}
+ workaroundTarget={selectedTarget.source !== "unknown" ? selectedTarget : undefined}
+ onRepairWorkaround={selectedTarget.source !== "unknown" ? () => onRepair(selectedTarget.appid) : undefined}
+ />
+ )}
+ {selectedTarget?.configured && (
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={handleProfileAction}>Remove profile</ButtonItem>
+ </PanelSectionRow>
+ )}
+ <ProfileDetails description={profileDescription} />
+ </Focusable>
+ );
+}