@decky/ui 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +504 -0
- package/README.md +30 -0
- package/dist/class-mapper.d.ts +7 -0
- package/dist/class-mapper.js +26 -0
- package/dist/components/Button.d.ts +5 -0
- package/dist/components/Button.js +2 -0
- package/dist/components/ButtonItem.d.ts +7 -0
- package/dist/components/ButtonItem.js +5 -0
- package/dist/components/Carousel.d.ts +19 -0
- package/dist/components/Carousel.js +2 -0
- package/dist/components/ControlsList.d.ts +7 -0
- package/dist/components/ControlsList.js +2 -0
- package/dist/components/Dialog.d.ts +33 -0
- package/dist/components/Dialog.js +24 -0
- package/dist/components/DialogCheckbox.d.ts +17 -0
- package/dist/components/DialogCheckbox.js +14 -0
- package/dist/components/Dropdown.d.ts +34 -0
- package/dist/components/Dropdown.js +5 -0
- package/dist/components/Field.d.ts +23 -0
- package/dist/components/Field.js +2 -0
- package/dist/components/FocusRing.d.ts +9 -0
- package/dist/components/FocusRing.js +2 -0
- package/dist/components/Focusable.d.ts +12 -0
- package/dist/components/Focusable.js +4 -0
- package/dist/components/FooterLegend.d.ts +66 -0
- package/dist/components/FooterLegend.js +32 -0
- package/dist/components/Item.d.ts +12 -0
- package/dist/components/Item.js +1 -0
- package/dist/components/Marquee.d.ts +14 -0
- package/dist/components/Marquee.js +2 -0
- package/dist/components/Menu.d.ts +29 -0
- package/dist/components/Menu.js +9 -0
- package/dist/components/Modal.d.ts +52 -0
- package/dist/components/Modal.js +29 -0
- package/dist/components/Panel.d.ts +11 -0
- package/dist/components/Panel.js +4 -0
- package/dist/components/ProgressBar.d.ts +21 -0
- package/dist/components/ProgressBar.js +6 -0
- package/dist/components/Scroll.d.ts +7 -0
- package/dist/components/Scroll.js +5 -0
- package/dist/components/SidebarNavigation.d.ts +21 -0
- package/dist/components/SidebarNavigation.js +4 -0
- package/dist/components/SliderField.d.ts +26 -0
- package/dist/components/SliderField.js +2 -0
- package/dist/components/Spinner.d.ts +2 -0
- package/dist/components/Spinner.js +2 -0
- package/dist/components/SteamSpinner.d.ts +2 -0
- package/dist/components/SteamSpinner.js +2 -0
- package/dist/components/Tabs.d.ts +16 -0
- package/dist/components/Tabs.js +48 -0
- package/dist/components/TextField.d.ts +22 -0
- package/dist/components/TextField.js +2 -0
- package/dist/components/Toggle.d.ts +8 -0
- package/dist/components/Toggle.js +2 -0
- package/dist/components/ToggleField.d.ts +9 -0
- package/dist/components/ToggleField.js +2 -0
- package/dist/components/index.d.ts +25 -0
- package/dist/components/index.js +25 -0
- package/dist/custom-components/ColorPickerModal.d.ts +12 -0
- package/dist/custom-components/ColorPickerModal.js +87 -0
- package/dist/custom-components/ReorderableList.d.ts +26 -0
- package/dist/custom-components/ReorderableList.js +84 -0
- package/dist/custom-components/SuspensefulImage.d.ts +7 -0
- package/dist/custom-components/SuspensefulImage.js +27 -0
- package/dist/custom-components/index.d.ts +3 -0
- package/dist/custom-components/index.js +3 -0
- package/dist/custom-hooks/index.d.ts +1 -0
- package/dist/custom-hooks/index.js +1 -0
- package/dist/custom-hooks/useQuickAccessVisible.d.ts +1 -0
- package/dist/custom-hooks/useQuickAccessVisible.js +22 -0
- package/dist/deck-hooks/index.d.ts +1 -0
- package/dist/deck-hooks/index.js +1 -0
- package/dist/deck-hooks/useParams.d.ts +1 -0
- package/dist/deck-hooks/useParams.js +2 -0
- package/dist/globals/SteamClient.d.ts +307 -0
- package/dist/globals/SteamClient.js +1 -0
- package/dist/globals/index.d.ts +2 -0
- package/dist/globals/index.js +2 -0
- package/dist/globals/stores.d.ts +50 -0
- package/dist/globals/stores.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/logger.d.ts +17 -0
- package/dist/logger.js +46 -0
- package/dist/modules/Router.d.ts +110 -0
- package/dist/modules/Router.js +102 -0
- package/dist/modules/index.d.ts +1 -0
- package/dist/modules/index.js +1 -0
- package/dist/utils/index.d.ts +12 -0
- package/dist/utils/index.js +23 -0
- package/dist/utils/patcher.d.ts +18 -0
- package/dist/utils/patcher.js +103 -0
- package/dist/utils/react.d.ts +19 -0
- package/dist/utils/react.js +89 -0
- package/dist/utils/static-classes.d.ts +52 -0
- package/dist/utils/static-classes.js +28 -0
- package/dist/webpack.d.ts +21 -0
- package/dist/webpack.js +102 -0
- package/package.json +89 -0
- package/src/class-mapper.ts +34 -0
- package/src/components/Button.ts +8 -0
- package/src/components/ButtonItem.ts +16 -0
- package/src/components/Carousel.ts +25 -0
- package/src/components/ControlsList.ts +14 -0
- package/src/components/Dialog.ts +90 -0
- package/src/components/DialogCheckbox.ts +36 -0
- package/src/components/Dropdown.ts +51 -0
- package/src/components/Field.ts +29 -0
- package/src/components/FocusRing.ts +15 -0
- package/src/components/Focusable.ts +21 -0
- package/src/components/FooterLegend.ts +67 -0
- package/src/components/Item.ts +13 -0
- package/src/components/Marquee.ts +20 -0
- package/src/components/Menu.ts +58 -0
- package/src/components/Modal.ts +119 -0
- package/src/components/Panel.ts +26 -0
- package/src/components/ProgressBar.ts +37 -0
- package/src/components/Scroll.ts +15 -0
- package/src/components/SidebarNavigation.ts +30 -0
- package/src/components/SliderField.ts +33 -0
- package/src/components/Spinner.ts +8 -0
- package/src/components/SteamSpinner.ts +7 -0
- package/src/components/Tabs.tsx +127 -0
- package/src/components/TextField.ts +28 -0
- package/src/components/Toggle.ts +14 -0
- package/src/components/ToggleField.ts +15 -0
- package/src/components/index.ts +25 -0
- package/src/custom-components/ColorPickerModal.tsx +132 -0
- package/src/custom-components/ReorderableList.tsx +183 -0
- package/src/custom-components/SuspensefulImage.tsx +44 -0
- package/src/custom-components/index.ts +3 -0
- package/src/custom-hooks/index.ts +1 -0
- package/src/custom-hooks/useQuickAccessVisible.ts +63 -0
- package/src/deck-hooks/index.ts +1 -0
- package/src/deck-hooks/useParams.ts +15 -0
- package/src/globals/SteamClient.ts +321 -0
- package/src/globals/index.ts +2 -0
- package/src/globals/stores.ts +50 -0
- package/src/index.ts +20 -0
- package/src/logger.ts +77 -0
- package/src/modules/Router.ts +174 -0
- package/src/modules/index.ts +1 -0
- package/src/utils/index.ts +43 -0
- package/src/utils/patcher.ts +160 -0
- package/src/utils/react.ts +141 -0
- package/src/utils/static-classes.ts +1104 -0
- package/src/webpack.ts +136 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ReactNode, FC } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Export, findModuleExport } from '../webpack';
|
|
4
|
+
import { createPropListRegex } from '../utils';
|
|
5
|
+
|
|
6
|
+
export interface SidebarNavigationPage {
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
content: ReactNode;
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
visible?: boolean;
|
|
11
|
+
hideTitle?: boolean;
|
|
12
|
+
identifier?: string;
|
|
13
|
+
route?: string;
|
|
14
|
+
link?: string;
|
|
15
|
+
padding?: 'none' | 'compact';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SidebarNavigationProps {
|
|
19
|
+
title?: string;
|
|
20
|
+
pages: (SidebarNavigationPage | 'separator')[];
|
|
21
|
+
showTitle?: boolean;
|
|
22
|
+
disableRouteReporting?: boolean;
|
|
23
|
+
page?: string;
|
|
24
|
+
onPageRequested?: (page: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sidebarNavigationRegex = createPropListRegex(["pages", "fnSetNavigateToPage", "disableRouteReporting"]);
|
|
28
|
+
export const SidebarNavigation = findModuleExport((e: Export) =>
|
|
29
|
+
e?.toString && sidebarNavigationRegex.test(e.toString()),
|
|
30
|
+
) as FC<SidebarNavigationProps>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { FC } from 'react';
|
|
2
|
+
|
|
3
|
+
import { CommonUIModule } from '../webpack';
|
|
4
|
+
import { ItemProps } from './Item';
|
|
5
|
+
|
|
6
|
+
export interface NotchLabel {
|
|
7
|
+
notchIndex: number;
|
|
8
|
+
label: string;
|
|
9
|
+
value?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SliderFieldProps extends ItemProps {
|
|
13
|
+
value: number;
|
|
14
|
+
min?: number;
|
|
15
|
+
max?: number;
|
|
16
|
+
step?: number;
|
|
17
|
+
notchCount?: number;
|
|
18
|
+
notchLabels?: NotchLabel[];
|
|
19
|
+
notchTicksVisible?: boolean;
|
|
20
|
+
showValue?: boolean;
|
|
21
|
+
resetValue?: number;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
editableValue?: boolean;
|
|
24
|
+
validValues?: 'steps' | 'range' | ((value: number) => boolean);
|
|
25
|
+
valueSuffix?: string;
|
|
26
|
+
minimumDpadGranularity?: number;
|
|
27
|
+
onChange?(value: number): void;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SliderField = Object.values(CommonUIModule).find((mod: any) =>
|
|
32
|
+
mod?.toString()?.includes('SliderField,fallback'),
|
|
33
|
+
) as FC<SliderFieldProps>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { FC, SVGAttributes } from 'react';
|
|
2
|
+
|
|
3
|
+
import { IconsModule } from '../webpack';
|
|
4
|
+
|
|
5
|
+
// TODO type this and other icons?
|
|
6
|
+
export const Spinner = Object.values(IconsModule)?.find(
|
|
7
|
+
(mod: any) => mod?.toString && /Spinner\)}\)?,.\.createElement\(\"path\",{d:\"M18 /.test(mod.toString()),
|
|
8
|
+
) as FC<SVGAttributes<SVGElement>>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FC, SVGAttributes } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Export, findModuleExport } from '../webpack';
|
|
4
|
+
|
|
5
|
+
export const SteamSpinner = findModuleExport(
|
|
6
|
+
(e: Export) => e?.toString?.()?.includes('Steam Spinner') && e?.toString?.()?.includes('src'),
|
|
7
|
+
) as FC<SVGAttributes<SVGElement>>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { FC, ReactNode, createElement, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { fakeRenderComponent, findInReactTree, sleep } from '../utils';
|
|
4
|
+
import { Export, findModuleByExport } from '../webpack';
|
|
5
|
+
import { FooterLegendProps } from './FooterLegend';
|
|
6
|
+
import { SteamSpinner } from './SteamSpinner';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Individual tab objects for the Tabs component
|
|
10
|
+
*
|
|
11
|
+
* `id` ID of this tab, can be used with activeTab to auto-focus a given tab
|
|
12
|
+
* `title` Title shown in the header bar
|
|
13
|
+
* `renderTabAddon` Return a {@link ReactNode} to render it next to the tab title, i.e. the counts for each tab on the Media page
|
|
14
|
+
* `content` Content of the tab
|
|
15
|
+
* `footer` Sets up button handlers and labels
|
|
16
|
+
*/
|
|
17
|
+
export interface Tab {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
renderTabAddon?: () => ReactNode;
|
|
21
|
+
content: ReactNode;
|
|
22
|
+
footer?: FooterLegendProps;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Props for the {@link Tabs}
|
|
27
|
+
*
|
|
28
|
+
* `tabs` array of {@link Tab}
|
|
29
|
+
* `activeTab` tab currently active, needs to be one of the tabs {@link Tab.id}, must be set using a `useState` in the `onShowTab` handler
|
|
30
|
+
* `onShowTab` Called when the active tab should change, needs to set `activeTab`. See example.
|
|
31
|
+
* `autoFocusContents` Whether to automatically focus the tab contents or not.
|
|
32
|
+
* `footer` Sets up button handlers and labels
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const Component: FC = () => {
|
|
36
|
+
* const [currentTab, setCurrentTab] = useState<string>("Tab1");
|
|
37
|
+
*
|
|
38
|
+
* return (
|
|
39
|
+
* <Tabs
|
|
40
|
+
* title="Theme Manager"
|
|
41
|
+
* activeTab={currentTabRoute}
|
|
42
|
+
* onShowTab={(tabID: string) => {
|
|
43
|
+
* setCurrentTabRoute(tabID);
|
|
44
|
+
* }}
|
|
45
|
+
* tabs={[
|
|
46
|
+
* {
|
|
47
|
+
* title: "Tab 1",
|
|
48
|
+
* content: <Tab1Component />,
|
|
49
|
+
* id: "Tab1",
|
|
50
|
+
* },
|
|
51
|
+
* {
|
|
52
|
+
* title: "Tab 2",
|
|
53
|
+
* content: <Tab2Component />,
|
|
54
|
+
* id: "Tab2",
|
|
55
|
+
* },
|
|
56
|
+
* ]}
|
|
57
|
+
* />
|
|
58
|
+
* );
|
|
59
|
+
* };
|
|
60
|
+
*/
|
|
61
|
+
export interface TabsProps {
|
|
62
|
+
tabs: Tab[];
|
|
63
|
+
activeTab: string;
|
|
64
|
+
onShowTab: (tab: string) => void;
|
|
65
|
+
autoFocusContents?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let tabsComponent: any;
|
|
69
|
+
|
|
70
|
+
const getTabs = async () => {
|
|
71
|
+
if (tabsComponent) return tabsComponent;
|
|
72
|
+
// @ts-ignore
|
|
73
|
+
while (!window?.DeckyPluginLoader?.routerHook?.routes) {
|
|
74
|
+
console.debug('[DFL:Tabs]: Waiting for Decky router...');
|
|
75
|
+
await sleep(500);
|
|
76
|
+
}
|
|
77
|
+
return (tabsComponent = fakeRenderComponent(
|
|
78
|
+
() => {
|
|
79
|
+
return findInReactTree(
|
|
80
|
+
findInReactTree(
|
|
81
|
+
// @ts-ignore
|
|
82
|
+
window.DeckyPluginLoader.routerHook.routes
|
|
83
|
+
.find((x: any) => x.props.path == '/library/app/:appid/achievements')
|
|
84
|
+
.props.children.type(),
|
|
85
|
+
(x) => x?.props?.scrollTabsTop,
|
|
86
|
+
).type({ appid: 1 }),
|
|
87
|
+
(x) => x?.props?.tabs,
|
|
88
|
+
).type;
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
useRef: () => ({ current: { reaction: { track: () => {} } } }),
|
|
92
|
+
useContext: () => ({ match: { params: { appid: 1 } } }),
|
|
93
|
+
useMemo: () => ({ data: {} }),
|
|
94
|
+
},
|
|
95
|
+
));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let oldTabs: any;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const oldTabsModule = findModuleByExport((e: Export) => e.Unbleed);
|
|
102
|
+
if (oldTabsModule)
|
|
103
|
+
oldTabs = Object.values(oldTabsModule).find((x: any) => x?.type?.toString()?.includes('((function(){'));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.error('Error finding oldTabs:', e);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Tabs component as used in the library and media tabs. See {@link TabsProps}.
|
|
110
|
+
* Unlike other components in `decky-frontend-lib`, this requires Decky Loader to be running.
|
|
111
|
+
*/
|
|
112
|
+
export const Tabs = (oldTabs ||
|
|
113
|
+
((props: TabsProps) => {
|
|
114
|
+
const found = tabsComponent;
|
|
115
|
+
const [tc, setTC] = useState<FC<TabsProps>>(found);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (found) return;
|
|
118
|
+
(async () => {
|
|
119
|
+
console.debug('[DFL:Tabs]: Finding component...');
|
|
120
|
+
const t = await getTabs();
|
|
121
|
+
console.debug('[DFL:Tabs]: Found!');
|
|
122
|
+
setTC(t);
|
|
123
|
+
})();
|
|
124
|
+
}, []);
|
|
125
|
+
console.log('tc', tc);
|
|
126
|
+
return tc ? createElement(tc, props) : <SteamSpinner />;
|
|
127
|
+
})) as FC<TabsProps>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ChangeEventHandler, HTMLAttributes, ReactNode, FC } from 'react';
|
|
2
|
+
|
|
3
|
+
import { CommonUIModule, Module } from '../webpack';
|
|
4
|
+
|
|
5
|
+
export interface TextFieldProps extends HTMLAttributes<HTMLInputElement> {
|
|
6
|
+
label?: ReactNode;
|
|
7
|
+
requiredLabel?: ReactNode;
|
|
8
|
+
description?: ReactNode;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
bShowCopyAction?: boolean;
|
|
11
|
+
bShowClearAction?: boolean;
|
|
12
|
+
bAlwaysShowClearAction?: boolean;
|
|
13
|
+
bIsPassword?: boolean;
|
|
14
|
+
rangeMin?: number;
|
|
15
|
+
rangeMax?: number;
|
|
16
|
+
mustBeNumeric?: boolean;
|
|
17
|
+
mustBeURL?: boolean;
|
|
18
|
+
mustBeEmail?: boolean;
|
|
19
|
+
focusOnMount?: boolean;
|
|
20
|
+
tooltip?: string;
|
|
21
|
+
inlineControls?: ReactNode;
|
|
22
|
+
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
23
|
+
value?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const TextField = Object.values(CommonUIModule).find(
|
|
27
|
+
(mod: Module) => mod?.validateUrl && mod?.validateEmail,
|
|
28
|
+
) as FC<TextFieldProps>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { FC } from 'react';
|
|
2
|
+
|
|
3
|
+
import { CommonUIModule } from '../webpack';
|
|
4
|
+
|
|
5
|
+
export interface ToggleProps {
|
|
6
|
+
value: boolean;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
onChange?(checked: boolean): void;
|
|
9
|
+
navRef?: any; // TODO figure out what this is
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Toggle = Object.values(CommonUIModule).find((mod: any) =>
|
|
13
|
+
mod?.render?.toString()?.includes('.ToggleOff)'),
|
|
14
|
+
) as FC<ToggleProps>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FC } from 'react';
|
|
2
|
+
|
|
3
|
+
import { CommonUIModule } from '../webpack';
|
|
4
|
+
import { ItemProps } from './Item';
|
|
5
|
+
|
|
6
|
+
export interface ToggleFieldProps extends ItemProps {
|
|
7
|
+
highlightOnFocus?: boolean;
|
|
8
|
+
checked: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onChange?(checked: boolean): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ToggleField = Object.values(CommonUIModule).find((mod: any) =>
|
|
14
|
+
mod?.render?.toString()?.includes('ToggleField,fallback'),
|
|
15
|
+
) as FC<ToggleFieldProps>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export * from './Button';
|
|
2
|
+
export * from './ButtonItem';
|
|
3
|
+
export * from './Carousel';
|
|
4
|
+
export * from './ControlsList';
|
|
5
|
+
export * from './Dialog';
|
|
6
|
+
export * from './DialogCheckbox';
|
|
7
|
+
export * from './Dropdown';
|
|
8
|
+
export * from './Field';
|
|
9
|
+
export * from './Focusable';
|
|
10
|
+
export * from './FocusRing';
|
|
11
|
+
export * from './FooterLegend';
|
|
12
|
+
export * from './Marquee';
|
|
13
|
+
export * from './Menu';
|
|
14
|
+
export * from './Modal';
|
|
15
|
+
export * from './Panel';
|
|
16
|
+
export * from './ProgressBar';
|
|
17
|
+
export * from './SidebarNavigation';
|
|
18
|
+
export * from './SliderField';
|
|
19
|
+
export * from './Spinner';
|
|
20
|
+
export * from './SteamSpinner';
|
|
21
|
+
export * from './Tabs';
|
|
22
|
+
export * from './TextField';
|
|
23
|
+
export * from './Toggle';
|
|
24
|
+
export * from './ToggleField';
|
|
25
|
+
export * from './Scroll';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { CSSProperties, FC, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { ConfirmModal, SliderField } from '../components';
|
|
4
|
+
import { gamepadSliderClasses } from '../utils/static-classes';
|
|
5
|
+
|
|
6
|
+
interface ColorPickerModalProps {
|
|
7
|
+
closeModal: () => void;
|
|
8
|
+
onConfirm?(HSLString: string): any;
|
|
9
|
+
title?: string;
|
|
10
|
+
defaultH?: number;
|
|
11
|
+
defaultS?: number;
|
|
12
|
+
defaultL?: number;
|
|
13
|
+
defaultA?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ColorPickerModal: FC<ColorPickerModalProps> = ({
|
|
17
|
+
closeModal,
|
|
18
|
+
onConfirm = () => {},
|
|
19
|
+
title = 'Color Picker',
|
|
20
|
+
defaultH = 0,
|
|
21
|
+
defaultS = 100,
|
|
22
|
+
defaultL = 50,
|
|
23
|
+
defaultA = 1,
|
|
24
|
+
}) => {
|
|
25
|
+
const [H, setH] = useState<number>(defaultH);
|
|
26
|
+
const [S, setS] = useState<number>(defaultS);
|
|
27
|
+
const [L, setL] = useState<number>(defaultL);
|
|
28
|
+
const [A, setA] = useState<number>(defaultA);
|
|
29
|
+
|
|
30
|
+
const colorPickerCSSVars = {
|
|
31
|
+
'--decky-color-picker-hvalue': `${H}`,
|
|
32
|
+
'--decky-color-picker-svalue': `${S}%`,
|
|
33
|
+
'--decky-color-picker-lvalue': `${L}%`,
|
|
34
|
+
'--decky-color-picker-avalue': `${A}`,
|
|
35
|
+
} as CSSProperties;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<ConfirmModal
|
|
39
|
+
bAllowFullSize
|
|
40
|
+
onCancel={closeModal}
|
|
41
|
+
onOK={() => {
|
|
42
|
+
onConfirm(`hsla(${H}, ${S}%, ${L}%, ${A})`);
|
|
43
|
+
closeModal();
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<style>
|
|
47
|
+
{`
|
|
48
|
+
/* This removes the cyan track color that is behind the slider head */
|
|
49
|
+
.ColorPicker_Container .${gamepadSliderClasses.SliderTrack} {
|
|
50
|
+
--left-track-color: #0000;
|
|
51
|
+
/* This is for compatibility with the "Colored Toggles" CSSLoader Theme*/
|
|
52
|
+
--colored-toggles-main-color: #0000;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ColorPicker_HSlider .${gamepadSliderClasses.SliderTrack} {
|
|
56
|
+
background: linear-gradient(
|
|
57
|
+
270deg,
|
|
58
|
+
hsla(360, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
|
|
59
|
+
hsla(270, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
|
|
60
|
+
hsla(180, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
|
|
61
|
+
hsla(90, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
|
|
62
|
+
hsla(0, var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.ColorPicker_SSlider .${gamepadSliderClasses.SliderTrack} {
|
|
67
|
+
background: linear-gradient(
|
|
68
|
+
90deg,
|
|
69
|
+
hsla(var(--decky-color-picker-hvalue), 0%, var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue)),
|
|
70
|
+
hsla(var(--decky-color-picker-hvalue), 100%, var(--decky-color-picker-lvalue), var(--decky-color-picker-avalue))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.ColorPicker_LSlider .${gamepadSliderClasses.SliderTrack} {
|
|
75
|
+
background: linear-gradient(
|
|
76
|
+
90deg,
|
|
77
|
+
hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), 0%, var(--decky-color-picker-avalue)),
|
|
78
|
+
hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), 50%, var(--decky-color-picker-avalue)),
|
|
79
|
+
hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), 100%, var(--decky-color-picker-avalue))
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.ColorPicker_ASlider .${gamepadSliderClasses.SliderTrack} {
|
|
84
|
+
background: linear-gradient(
|
|
85
|
+
90deg,
|
|
86
|
+
hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), 0),
|
|
87
|
+
hsla(var(--decky-color-picker-hvalue), var(--decky-color-picker-svalue), var(--decky-color-picker-lvalue), 1)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
`}
|
|
91
|
+
</style>
|
|
92
|
+
<div
|
|
93
|
+
className="ColorPicker_ColorDisplayContainer"
|
|
94
|
+
style={{
|
|
95
|
+
display: 'flex',
|
|
96
|
+
justifyContent: 'space-between',
|
|
97
|
+
alignItems: 'center',
|
|
98
|
+
marginBottom: '1em',
|
|
99
|
+
// theres a large header by default on the modal, so this just pushes it up into that unused space
|
|
100
|
+
marginTop: '-2.5em',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<div>
|
|
104
|
+
<span style={{ fontSize: '1.5em' }}>
|
|
105
|
+
<b>{title}</b>
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
<div
|
|
109
|
+
style={{
|
|
110
|
+
backgroundColor: `hsla(${H}, ${S}%, ${L}%, ${A})`,
|
|
111
|
+
width: '40px',
|
|
112
|
+
height: '40px',
|
|
113
|
+
}}
|
|
114
|
+
></div>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="ColorPicker_Container" style={colorPickerCSSVars}>
|
|
117
|
+
<div className="ColorPicker_HSlider">
|
|
118
|
+
<SliderField showValue editableValue label="Hue" value={H} min={0} max={360} onChange={setH} />
|
|
119
|
+
</div>
|
|
120
|
+
<div className="ColorPicker_SSlider">
|
|
121
|
+
<SliderField showValue editableValue label="Saturation" value={S} min={0} max={100} onChange={setS} />
|
|
122
|
+
</div>
|
|
123
|
+
<div className="ColorPicker_LSlider">
|
|
124
|
+
<SliderField showValue editableValue label="Lightness" value={L} min={0} max={100} onChange={setL} />
|
|
125
|
+
</div>
|
|
126
|
+
<div className="ColorPicker_ASlider">
|
|
127
|
+
<SliderField showValue editableValue label="Alpha" value={A} step={0.1} min={0} max={1} onChange={setA} />
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</ConfirmModal>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Fragment, JSXElementConstructor, ReactElement, ReactNode, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Field, FieldProps, Focusable, GamepadButton } from '../components';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A ReorderableList entry of type <T>.
|
|
7
|
+
* @param label The name of this entry in the list.
|
|
8
|
+
* @param data Optional data to connect to this entry.
|
|
9
|
+
* @param position The position of this entry in the list.
|
|
10
|
+
*/
|
|
11
|
+
export type ReorderableEntry<T> = {
|
|
12
|
+
label: ReactNode;
|
|
13
|
+
data?: T;
|
|
14
|
+
position: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Properties for a ReorderableList component of type <T>.
|
|
19
|
+
*
|
|
20
|
+
* @param animate If the list should animate. @default true
|
|
21
|
+
*/
|
|
22
|
+
export type ReorderableListProps<T> = {
|
|
23
|
+
entries: ReorderableEntry<T>[];
|
|
24
|
+
onSave: (entries: ReorderableEntry<T>[]) => void;
|
|
25
|
+
interactables?: JSXElementConstructor<{ entry: ReorderableEntry<T> }>;
|
|
26
|
+
fieldProps?: FieldProps;
|
|
27
|
+
animate?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A component for creating reorderable lists.
|
|
32
|
+
*
|
|
33
|
+
* See an example implementation {@linkplain https://github.com/Tormak9970/Component-Testing-Plugin/blob/main/src/testing-window/ReorderableListTest.tsx here}.
|
|
34
|
+
*/
|
|
35
|
+
export function ReorderableList<T>(props: ReorderableListProps<T>) {
|
|
36
|
+
if (props.animate === undefined) props.animate = true;
|
|
37
|
+
const [entryList, setEntryList] = useState<ReorderableEntry<T>[]>(
|
|
38
|
+
props.entries.sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position),
|
|
39
|
+
);
|
|
40
|
+
const [reorderEnabled, setReorderEnabled] = useState<boolean>(false);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setEntryList(props.entries.sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position));
|
|
44
|
+
}, [props.entries]);
|
|
45
|
+
|
|
46
|
+
function toggleReorderEnabled(): void {
|
|
47
|
+
let newReorderValue = !reorderEnabled;
|
|
48
|
+
setReorderEnabled(newReorderValue);
|
|
49
|
+
|
|
50
|
+
if (!newReorderValue) {
|
|
51
|
+
props.onSave(entryList);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveOnBackout(e: Event) {
|
|
56
|
+
const event = e as CustomEvent;
|
|
57
|
+
if (event.detail.button == GamepadButton.CANCEL && reorderEnabled) {
|
|
58
|
+
setReorderEnabled(!reorderEnabled);
|
|
59
|
+
props.onSave(entryList);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Fragment>
|
|
65
|
+
<div
|
|
66
|
+
style={{
|
|
67
|
+
width: 'inherit',
|
|
68
|
+
height: 'inherit',
|
|
69
|
+
flex: '1 1 1px',
|
|
70
|
+
scrollPadding: '48px 0px',
|
|
71
|
+
display: 'flex',
|
|
72
|
+
flexDirection: 'column',
|
|
73
|
+
justifyContent: 'flex-start',
|
|
74
|
+
alignContent: 'stretch',
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<Focusable
|
|
78
|
+
onSecondaryButton={toggleReorderEnabled}
|
|
79
|
+
onSecondaryActionDescription={reorderEnabled ? 'Save Order' : 'Reorder'}
|
|
80
|
+
onClick={toggleReorderEnabled}
|
|
81
|
+
onButtonDown={saveOnBackout}
|
|
82
|
+
>
|
|
83
|
+
{entryList.map((entry: ReorderableEntry<T>) => (
|
|
84
|
+
<ReorderableItem
|
|
85
|
+
animate={props.animate!}
|
|
86
|
+
listData={entryList}
|
|
87
|
+
entryData={entry}
|
|
88
|
+
reorderEntryFunc={setEntryList}
|
|
89
|
+
reorderEnabled={reorderEnabled}
|
|
90
|
+
fieldProps={props.fieldProps}
|
|
91
|
+
>
|
|
92
|
+
{props.interactables ? <props.interactables entry={entry} /> : null}
|
|
93
|
+
</ReorderableItem>
|
|
94
|
+
))}
|
|
95
|
+
</Focusable>
|
|
96
|
+
</div>
|
|
97
|
+
</Fragment>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Properties for a ReorderableItem component of type <T>
|
|
103
|
+
*/
|
|
104
|
+
export type ReorderableListEntryProps<T> = {
|
|
105
|
+
fieldProps?: FieldProps;
|
|
106
|
+
listData: ReorderableEntry<T>[];
|
|
107
|
+
entryData: ReorderableEntry<T>;
|
|
108
|
+
reorderEntryFunc: CallableFunction;
|
|
109
|
+
reorderEnabled: boolean;
|
|
110
|
+
animate: boolean;
|
|
111
|
+
children: ReactElement | null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function ReorderableItem<T>(props: ReorderableListEntryProps<T>) {
|
|
115
|
+
const [isSelected, _setIsSelected] = useState<boolean>(false);
|
|
116
|
+
const [isSelectedLastFrame, setIsSelectedLastFrame] = useState<boolean>(false);
|
|
117
|
+
const listEntries = props.listData;
|
|
118
|
+
|
|
119
|
+
function onReorder(e: Event): void {
|
|
120
|
+
if (!props.reorderEnabled) return;
|
|
121
|
+
|
|
122
|
+
const event = e as CustomEvent;
|
|
123
|
+
const currentIdx = listEntries.findIndex((entryData: ReorderableEntry<T>) => entryData === props.entryData);
|
|
124
|
+
const currentIdxValue = listEntries[currentIdx];
|
|
125
|
+
if (currentIdx < 0) return;
|
|
126
|
+
|
|
127
|
+
let targetPosition: number = -1;
|
|
128
|
+
if (event.detail.button == GamepadButton.DIR_DOWN) {
|
|
129
|
+
targetPosition = currentIdxValue.position + 1;
|
|
130
|
+
} else if (event.detail.button == GamepadButton.DIR_UP) {
|
|
131
|
+
targetPosition = currentIdxValue.position - 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (targetPosition >= listEntries.length || targetPosition < 0) return;
|
|
135
|
+
|
|
136
|
+
let otherToUpdate = listEntries.find((entryData: ReorderableEntry<T>) => entryData.position === targetPosition);
|
|
137
|
+
if (!otherToUpdate) return;
|
|
138
|
+
|
|
139
|
+
let currentPosition = currentIdxValue.position;
|
|
140
|
+
|
|
141
|
+
currentIdxValue.position = otherToUpdate.position;
|
|
142
|
+
otherToUpdate.position = currentPosition;
|
|
143
|
+
|
|
144
|
+
props.reorderEntryFunc(
|
|
145
|
+
[...listEntries].sort((a: ReorderableEntry<T>, b: ReorderableEntry<T>) => a.position - b.position),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function setIsSelected(val: boolean) {
|
|
150
|
+
_setIsSelected(val);
|
|
151
|
+
// Wait 3 frames, then set. I have no idea why, but if you dont wait long enough it doesn't work.
|
|
152
|
+
for (let i = 0; i < 3; i++) await new Promise((res) => requestAnimationFrame(res));
|
|
153
|
+
setIsSelectedLastFrame(val);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
style={
|
|
159
|
+
props.animate
|
|
160
|
+
? {
|
|
161
|
+
transition:
|
|
162
|
+
isSelected || isSelectedLastFrame
|
|
163
|
+
? ''
|
|
164
|
+
: 'transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.3s cubic-bezier(0.25, 1, 0.5, 1)', // easeOutQuart https://easings.net/#easeOutQuart
|
|
165
|
+
transform: !props.reorderEnabled || isSelected ? 'scale(1)' : 'scale(0.9)',
|
|
166
|
+
opacity: !props.reorderEnabled || isSelected ? 1 : 0.7,
|
|
167
|
+
}
|
|
168
|
+
: {}
|
|
169
|
+
}
|
|
170
|
+
>
|
|
171
|
+
<Field
|
|
172
|
+
label={props.entryData.label}
|
|
173
|
+
{...props.fieldProps}
|
|
174
|
+
focusable={!props.children}
|
|
175
|
+
onButtonDown={onReorder}
|
|
176
|
+
onGamepadBlur={() => setIsSelected(false)}
|
|
177
|
+
onGamepadFocus={() => setIsSelected(true)}
|
|
178
|
+
>
|
|
179
|
+
<Focusable style={{ display: 'flex', width: '100%', position: 'relative' }}>{props.children}</Focusable>
|
|
180
|
+
</Field>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { FC, ImgHTMLAttributes, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { Spinner } from '../components';
|
|
5
|
+
|
|
6
|
+
interface SuspensefulImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
|
7
|
+
suspenseWidth?: string | number;
|
|
8
|
+
suspenseHeight?: string | number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SuspensefulImage: FC<SuspensefulImageProps> = (props) => {
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
const [error, setError] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setLoading(true);
|
|
17
|
+
setError(false);
|
|
18
|
+
const img = new Image();
|
|
19
|
+
img.src = props.src || '';
|
|
20
|
+
img.addEventListener('load', () => {
|
|
21
|
+
setLoading(false);
|
|
22
|
+
});
|
|
23
|
+
img.addEventListener('error', () => {
|
|
24
|
+
setError(true);
|
|
25
|
+
});
|
|
26
|
+
}, [props.src]);
|
|
27
|
+
|
|
28
|
+
return loading ? (
|
|
29
|
+
<div
|
|
30
|
+
style={{
|
|
31
|
+
width: props.suspenseWidth || props.style?.width,
|
|
32
|
+
height: props.suspenseHeight || props.style?.height,
|
|
33
|
+
background: 'rgba(255, 255, 255, 0.2)',
|
|
34
|
+
display: 'flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'center',
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{error ? 'Missing image' : <Spinner style={{ height: '48px' }} />}
|
|
40
|
+
</div>
|
|
41
|
+
) : (
|
|
42
|
+
<img {...props} />
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './useQuickAccessVisible';
|