summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-11 09:05:14 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-11 09:05:14 -0400
commitd2bfdafa92f3d31cc5392bf8a5a1f1df5c2358ce (patch)
tree102c12dc843ce64dd21599c2cf8ae3d5a3bd0104 /src/components
parent904e2e6131071c3b132d3148947b613c2830b1bb (diff)
downloaddecky-lsfg-vk-d2bfdafa92f3d31cc5392bf8a5a1f1df5c2358ce.tar.gz
decky-lsfg-vk-d2bfdafa92f3d31cc5392bf8a5a1f1df5c2358ce.zip
flatpak correctness, ui alignment, tests
Diffstat (limited to 'src/components')
-rw-r--r--src/components/CollapsibleItemGroup.tsx79
-rw-r--r--src/components/Content.tsx56
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx26
-rw-r--r--src/components/FlatpakTab.tsx79
-rw-r--r--src/components/GameConfigurationSelector.tsx85
5 files changed, 204 insertions, 121 deletions
diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx
new file mode 100644
index 0000000..a66a8ba
--- /dev/null
+++ b/src/components/CollapsibleItemGroup.tsx
@@ -0,0 +1,79 @@
+import { ButtonItem, Field, PanelSectionRow } from "@decky/ui";
+import { type RefObject } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+
+export interface CollapsibleItem {
+ id: string;
+ label: string;
+ description: string;
+}
+
+export const collapsibleItemGroupStyles = `
+ .LSFG_GameGroupCollapseButton_Container > div > div > div > button,
+ .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button {
+ height: 24px !important;
+ min-height: 24px !important;
+ padding: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ }
+
+ .LSFG_GameGroupCollapseButton_Container svg {
+ display: block;
+ margin: 0;
+ }
+`;
+
+interface Props {
+ title: string;
+ items: CollapsibleItem[];
+ collapsed: boolean;
+ onToggle: () => void;
+ onSelect: (id: string) => void;
+ toggleRef?: RefObject<HTMLDivElement>;
+}
+
+export function CollapsibleItemGroup({
+ title,
+ items,
+ collapsed,
+ onToggle,
+ onSelect,
+ toggleRef,
+}: Props) {
+ if (items.length === 0) return null;
+
+ return (
+ <>
+ <PanelSectionRow>
+ <Field label={`${title} (${items.length})`} bottomSeparator="none" />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <div
+ ref={toggleRef}
+ className="LSFG_GameGroupCollapseButton_Container"
+ style={{ marginTop: "-2px", marginBottom: "4px" }}
+ >
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={onToggle}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </div>
+ </PanelSectionRow>
+ {!collapsed && items.map((item) => (
+ <PanelSectionRow key={item.id}>
+ <Field
+ label={item.label}
+ description={item.description}
+ onActivate={() => onSelect(item.id)}
+ highlightOnFocus
+ />
+ </PanelSectionRow>
+ ))}
+ </>
+ );
+}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index ce3c018..0d49f89 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,4 +1,4 @@
-import { Tabs } from "@decky/ui";
+import { Field, PanelSection, PanelSectionRow, Tabs } from "@decky/ui";
import { useEffect, useRef, useState } from "react";
import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
@@ -6,6 +6,7 @@ import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallation } from "../hooks/useLsfgHooks";
import { tabStyles } from "../styles";
+import { resolveNowPlayingTarget } from "../utils/nowPlaying";
import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab";
@@ -80,10 +81,13 @@ export function Content() {
const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true);
const previousRunningWorkload = useRef<string | null>(null);
const runningFlatpak = flatpak.runningApp;
- const hasNowPlaying = Boolean(runningGame?.configured || runningFlatpak);
- const runningWorkload = runningGame?.configured
- ? `steam:${runningGame.appid}`
- : runningFlatpak ? `flatpak:${runningFlatpak.app_id}` : null;
+ const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak);
+ const hasNowPlaying = Boolean(nowPlayingTarget);
+ const runningWorkload = nowPlayingTarget
+ ? nowPlayingTarget.kind === "flatpak"
+ ? `flatpak:${nowPlayingTarget.app.app_id}`
+ : `steam:${nowPlayingTarget.game.appid}`
+ : null;
useEffect(() => {
if (!setupComplete) {
@@ -136,26 +140,27 @@ export function Content() {
/>
);
- const nowPlaying = runningGame?.configured ? (
+ const nowPlaying = nowPlayingTarget?.kind === "steam" ? (
<NowPlayingTab
- game={runningGame}
+ game={nowPlayingTarget.game}
config={runningConfig}
onConfigChange={async (field, value) => {
- await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true);
+ await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true);
}}
/>
- ) : runningFlatpak ? (
+ ) : nowPlayingTarget?.kind === "flatpak" ? (
<FlatpakNowPlayingTab
- app={runningFlatpak}
- busy={flatpak.busyAppId === runningFlatpak.app_id}
+ app={nowPlayingTarget.app}
+ launcher={nowPlayingTarget.launcher}
onConfigChange={flatpak.updateConfig}
- onWorkaroundChange={flatpak.updateWorkarounds}
/>
- ) : null;
+ ) : (
+ <NowPlayingTabPlaceholder />
+ );
const tabs = setupComplete
? [
- ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []),
+ { id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying },
{
id: "Games",
title: tabIcons.games,
@@ -198,13 +203,34 @@ export function Content() {
]
: [{ id: "Setup", title: tabIcons.setup, content: setup }];
+ const availableTabIds = new Set(tabs.map(({ id }) => id));
+ const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup";
+
return (
<div
className="lsfg-vk-tabs"
style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }}
>
<style>{tabStyles}</style>
- <Tabs activeTab={!showDebugTab && tab === "ConfigFile" ? "Games" : tab} onShowTab={setTab} tabs={tabs} />
+ <Tabs
+ activeTab={activeTab}
+ onShowTab={(nextTab: string) => {
+ if (availableTabIds.has(nextTab)) setTab(nextTab);
+ }}
+ tabs={tabs}
+ />
+ </div>
+ );
+}
+
+function NowPlayingTabPlaceholder() {
+ return (
+ <div>
+ <PanelSection title="Now Playing">
+ <PanelSectionRow>
+ <Field label="Nothing running" description="Start an enabled Steam, non-Steam, or Flatpak target to configure it here." />
+ </PanelSectionRow>
+ </PanelSection>
</div>
);
}
diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx
index 9e5a0db..9623b11 100644
--- a/src/components/FlatpakNowPlayingTab.tsx
+++ b/src/components/FlatpakNowPlayingTab.tsx
@@ -1,17 +1,16 @@
import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
-import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi";
+import type { GameTarget } from "../hooks/useGameConfiguration";
import { ConfigurationSection } from "./ConfigurationSection";
-import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
import { FpsMultiplierControl } from "./FpsMultiplierControl";
interface Props {
app: FlatpakApp;
- busy: boolean;
+ launcher: GameTarget | null;
onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
- onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
}
-export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: Props) {
+export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) {
if (!app.config) return null;
const changeConfig = async (
field: keyof LsfgConfig,
@@ -24,16 +23,21 @@ export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundCh
<Focusable>
<PanelSection title="Now Playing">
<PanelSectionRow>
- <Field label={app.app_name} description={`Flatpak · ${app.app_id}`} />
+ <Field
+ label={launcher?.name || app.app_name}
+ description={launcher
+ ? `${launcher.nonSteam ? "Steam shortcut" : "Steam"} · Running in ${app.app_name} · Flatpak`
+ : `Flatpak · ${app.app_id}`}
+ />
</PanelSectionRow>
+ {launcher && (
+ <PanelSectionRow>
+ <Field label="Controls" description={`${app.app_name} profile · ${app.app_id}`} />
+ </PanelSectionRow>
+ )}
</PanelSection>
<FpsMultiplierControl config={app.config} onConfigChange={changeConfig} />
<ConfigurationSection config={app.config} onConfigChange={changeConfig} />
- <FlatpakWorkaroundsSection
- state={app.workarounds}
- disabled={busy}
- onChange={(state) => onWorkaroundChange(app.app_id, state)}
- />
</Focusable>
);
}
diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx
index c5e27f5..31bfc2d 100644
--- a/src/components/FlatpakTab.tsx
+++ b/src/components/FlatpakTab.tsx
@@ -1,7 +1,8 @@
import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui";
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FaArrowLeft } from "react-icons/fa";
import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { CollapsibleItemGroup, collapsibleItemGroupStyles } from "./CollapsibleItemGroup";
import { ConfigurationSection } from "./ConfigurationSection";
import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
import { FpsMultiplierControl } from "./FpsMultiplierControl";
@@ -19,6 +20,24 @@ interface Props {
onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
}
+function usePersistentCollapsed(key: string) {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ return localStorage.getItem(key) !== "false";
+ } catch {
+ return true;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, String(collapsed));
+ } catch {}
+ }, [collapsed, key]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
export function FlatpakTab({
apps,
runningApp,
@@ -36,31 +55,47 @@ export function FlatpakTab({
[apps, selectedAppId],
);
const close = useCallback(() => setSelectedAppId(null), []);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed("lsfg-flatpak-enabled-collapsed-v1");
+ const [availableCollapsed, toggleAvailable] = usePersistentCollapsed("lsfg-flatpak-available-collapsed-v1");
+ const enabledToggleRef = useRef<HTMLDivElement>(null);
+
+ const enabledApps = useMemo(
+ () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)),
+ [apps],
+ );
+ const availableApps = useMemo(
+ () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)),
+ [apps],
+ );
+ const itemFor = (app: FlatpakApp) => ({
+ id: app.app_id,
+ label: app.app_name,
+ description: `${app.app_id} · ${app.prepared && !app.owned ? "Prepared externally" : "Available"}`,
+ });
if (!selectedAppId) {
return (
<PanelSection title="Flatpak">
- {/* <PanelSectionRow>
- <Field
- label="Flatpak applications"
- description="Enable LSFG-VK directly for a Flatpak. Steam shortcuts and launcher scripts are not modified."
- />
- </PanelSectionRow> */}
- {apps.map((app) => {
- const status = app.enabled
- ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled"
- : app.prepared && !app.owned ? "Prepared externally" : "Available";
- return (
- <PanelSectionRow key={app.app_id}>
- <Field
- label={app.app_name}
- description={`${app.app_id} · ${status}`}
- onActivate={() => setSelectedAppId(app.app_id)}
- highlightOnFocus
- />
- </PanelSectionRow>
- );
- })}
+ <style>{collapsibleItemGroupStyles}</style>
+ <CollapsibleItemGroup
+ title="Enabled"
+ items={enabledApps.map((app) => ({
+ id: app.app_id,
+ label: app.app_name,
+ description: `${app.app_id}${app.app_id === runningApp?.app_id ? " · Running" : ""}`,
+ }))}
+ collapsed={enabledCollapsed}
+ onToggle={toggleEnabled}
+ onSelect={setSelectedAppId}
+ toggleRef={enabledToggleRef}
+ />
+ <CollapsibleItemGroup
+ title="Available"
+ items={availableApps.map(itemFor)}
+ collapsed={availableCollapsed}
+ onToggle={toggleAvailable}
+ onSelect={setSelectedAppId}
+ />
{apps.length === 0 && !loading && (
<PanelSectionRow>
<Field label="No Flatpak applications found" />
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index 1f0bc73..ee87423 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,7 +1,7 @@
import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui";
-import { useEffect, useRef, useState, type RefObject } from "react";
-import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { useEffect, useRef, useState } from "react";
import { GameTarget } from "../hooks/useGameConfiguration";
+import { CollapsibleItemGroup, collapsibleItemGroupStyles } from "./CollapsibleItemGroup";
interface Props {
targets: GameTarget[];
@@ -38,57 +38,6 @@ function targetDescription(game: GameTarget): string {
return game.nonSteam ? "Non-Steam" : "Steam";
}
-function GameGroup({
- title,
- games,
- collapsed,
- onToggle,
- onSelect,
- toggleRef,
-}: {
- title: string;
- games: GameTarget[];
- collapsed: boolean;
- onToggle: () => void;
- onSelect: (appid: string) => void;
- toggleRef?: RefObject<HTMLDivElement>;
-}) {
- if (games.length === 0) return null;
-
- return (
- <>
- <PanelSectionRow>
- <Field label={title + " (" + games.length + ")"} bottomSeparator="none" />
- </PanelSectionRow>
- <PanelSectionRow>
- <div
- ref={toggleRef}
- className="LSFG_GameGroupCollapseButton_Container"
- style={{ marginTop: "-2px", marginBottom: "4px" }}
- >
- <ButtonItem
- layout="below"
- bottomSeparator={collapsed ? "standard" : "none"}
- onClick={onToggle}
- >
- {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
- </ButtonItem>
- </div>
- </PanelSectionRow>
- {!collapsed && games.map((game) => (
- <PanelSectionRow key={game.appid}>
- <Field
- label={game.name}
- description={targetDescription(game)}
- onActivate={() => onSelect(game.appid)}
- highlightOnFocus
- />
- </PanelSectionRow>
- ))}
- </>
- );
-}
-
export function GameConfigurationSelector({
targets,
runningGame,
@@ -105,6 +54,11 @@ export function GameConfigurationSelector({
});
const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
+ const toItem = (game: GameTarget) => ({
+ id: game.appid,
+ label: game.name,
+ description: targetDescription(game),
+ });
const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
const enabledToggleRef = useRef<HTMLDivElement>(null);
@@ -146,39 +100,24 @@ export function GameConfigurationSelector({
return (
<>
<style>
- {`
- .LSFG_GameGroupCollapseButton_Container > div > div > div > button,
- .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button {
- height: 24px !important;
- min-height: 24px !important;
- padding: 0 !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- }
-
- .LSFG_GameGroupCollapseButton_Container svg {
- display: block;
- margin: 0;
- }
- `}
+ {collapsibleItemGroupStyles}
</style>
{targets.length === 0 && (
<PanelSectionRow>
<Field label="No installed games" description="Steam has not reported any eligible games" />
</PanelSectionRow>
)}
- <GameGroup
+ <CollapsibleItemGroup
title="Enabled"
- games={enabledGames}
+ items={enabledGames.map(toItem)}
collapsed={enabledCollapsed}
onToggle={toggleEnabled}
onSelect={onSelect}
toggleRef={enabledToggleRef}
/>
- <GameGroup
+ <CollapsibleItemGroup
title="Available"
- games={availableGames}
+ items={availableGames.map(toItem)}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}