- {mergeParams.filters.map((filter, index) => (
-
+ {mergeParams.filters.map((filter) => (
+
-
- {index !== mergeParams.filters.length - 1 && mergeParams.mode}
-
))}
diff --git a/src/components/filters/FilterPreview.tsx b/src/components/filters/FilterPreview.tsx
index 2959443..9e2cf08 100644
--- a/src/components/filters/FilterPreview.tsx
+++ b/src/components/filters/FilterPreview.tsx
@@ -1,104 +1,131 @@
-import { Fragment, VFC } from "react";
-import { FilterType, TabFilterSettings, compatCategoryToLabel } from "./Filters";
+import { Fragment, VFC, createElement } from "react";
+import { FilterIcons, FilterType, TabFilterSettings, compatCategoryToLabel } from "./Filters";
import { dateToLabel } from '../generic/DatePickers';
import { capitalizeEachWord } from '../../lib/Utils';
import { MicroSDeckInterop } from '../../lib/controllers/MicroSDeckInterop';
+type FilterPreviewGenericProps = {
+ filter: TabFilterSettings
,
+ displayData: string | undefined,
+ isInverted?: boolean
+};
+
+const FilterPreviewGeneric: VFC = ({ filter, displayData, isInverted }) => {
+ return (
+
+
+ {createElement(FilterIcons[filter.type], { size: '.8em' })}
+
+
+ {capitalizeEachWord(filter.type)}{' - ' + displayData + (isInverted ? " (inverted)" : "")}
+
+
+ );
+}
+
type FilterPreviewProps = {
filter: TabFilterSettings;
};
-
const CollectionFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.name ?? filter.params.id}{filter.inverted ? " (inverted)" : ""}
;
+ return ;
};
const InstalledFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.installed ? "yes" : "no"}
;
+ return ;
};
const RegexFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.regex}{filter.inverted ? " (inverted)" : ""}
;
+ return ;
};
const FriendsFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.friends.length} {filter.params.friends.length == 1 ? "friend" : "friends"}{filter.inverted ? " (inverted)" : ""}
;
+ return ;
};
const TagsFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.tags.length} {filter.params.tags.length == 1 ? "tag" : "tags"}{filter.inverted ? " (inverted)" : ""}
;
+ return ;
};
const WhitelistFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.games.length} whitelisted
;
+ return ;
};
const BlackListFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.games.length} blacklisted
;
+ return ;
};
const MergeFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.filters.length} grouped filters{filter.inverted ? " (inverted)" : ""}
;
+ return ;
};
const PlatformFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.platform === "steam" ? "Steam" : "Non Steam"}
;
+ return ;
};
const DeckCompatFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{compatCategoryToLabel(filter.params.category)}{filter.inverted ? " (inverted)" : ""}
;
+ return ;
};
const ReviewScoreFilterPreview: VFC> = ({ filter }) => {
const { scoreThreshold, condition, type } = filter.params;
- return {capitalizeEachWord(filter.type) + ' - '}{type === 'metacritic' ? `Metacritic of ${scoreThreshold} or ${condition === 'above' ? 'higher' : 'lower'}` : `At ${condition === 'above' ? 'least' : 'most'} ${scoreThreshold}% positive Steam reviews`}
;
+ return ;
};
const TimePlayedFilterPreview: VFC> = ({ filter }) => {
const { timeThreshold, condition, units } = filter.params;
- return {capitalizeEachWord(filter.type) + ' - '}{`${timeThreshold} ${timeThreshold === 1 ? units.slice(0, -1) : units} or ${condition === 'above' ? 'more' : 'less'}`}
;
+ return ;
};
const SizeOnDiskFilterPreview: VFC> = ({ filter }) => {
const { gbThreshold, condition } = filter.params;
- return {capitalizeEachWord(filter.type) + ' - '}{`${gbThreshold} GB or ${condition === 'above' ? 'more' : 'less'}`}
;
+ return ;
};
const ReleaseDateFilterPreview: VFC> = ({ filter }) => {
+ let displayData: string;
+
if (filter.params.date) {
const { day, month, year } = filter.params.date;
- return {capitalizeEachWord(filter.type) + ' - '}{`${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`}
;
+ displayData = `${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`;
} else {
const daysAgo = filter.params.daysAgo;
- return {capitalizeEachWord(filter.type) + ' - '}{`${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`}
;
+ displayData = `${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`;
}
+
+ return ;
};
const LastPlayedFilterPreview: VFC> = ({ filter }) => {
+ let displayData: string;
+
if (filter.params.date) {
const { day, month, year } = filter.params.date;
- return {capitalizeEachWord(filter.type) + ' - '}{`${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`}
;
+ displayData = `${!day ? 'In' : 'On'} or ${filter.params.condition === 'above' ? 'after' : 'before'} ${dateToLabel(year, month, day, { dateStyle: 'long' })}`;
} else {
const daysAgo = filter.params.daysAgo;
- return {capitalizeEachWord(filter.type) + ' - '}{`${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`}
;
+ displayData = `${daysAgo} day${daysAgo === 1 ? '' : 's'} ago or ${filter.params.condition === 'above' ? 'later' : 'earlier'}`;
}
+
+ return ;
};
+
const DemoFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.isDemo ? "yes" : "no"}
;
+ return ;
};
const StreamableFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.isStreamable ? "yes" : "no"}
;
+ return ;
};
const SteamFeaturesFilterPreview: VFC> = ({ filter }) => {
- return {capitalizeEachWord(filter.type) + ' - '}{filter.params.features.length} {filter.params.features.length == 1 ? "feature" : "features"}{filter.inverted ? " (inverted)" : ""}
;
+ return ;
};
const SDCardFilterPreview: VFC> = ({ filter }) => {
const isInsertCard = !filter.params.card;
- const card = (MicroSDeckInterop.isInstallOk() && window.MicroSDeck?.CardsAndGames.find(([card]) => card.uid === filter.params.card)?.[0].name) || filter.params.card
- return {capitalizeEachWord(filter.type) + ' - '}{isInsertCard ? 'Inserted Card' : card}{filter.inverted ? " (inverted)" : ""}
;
+ const card = (MicroSDeckInterop.isInstallOk() && window.MicroSDeck?.CardsAndGames.find(([card]) => card.uid === filter.params.card)?.[0].name) || filter.params.card;
+ return ;
}
/**
diff --git a/src/components/filters/FilterSelect.tsx b/src/components/filters/FilterSelect.tsx
index c0bb632..7d81902 100644
--- a/src/components/filters/FilterSelect.tsx
+++ b/src/components/filters/FilterSelect.tsx
@@ -1,6 +1,6 @@
-import { Fragment, VFC, useEffect, useState } from "react";
+import { Fragment, VFC, createElement, useEffect, useState } from "react";
import { Focusable, ModalRoot, SingleDropdownOption } from "decky-frontend-lib";
-import { FilterDefaultParams, FilterDescriptions, FilterType } from "./Filters";
+import { FilterDefaultParams, FilterDescriptions, FilterIcons, FilterType } from "./Filters";
import { capitalizeEachWord } from "../../lib/Utils";
import { FilterSelectStyles, achievementClasses, mainMenuAppRunningClasses } from "../styles/FilterSelectionStyles";
import { IoFilter } from 'react-icons/io5'
@@ -54,12 +54,15 @@ interface FilterSelectElement {
const FilterSelectElement: VFC = ({ filterType, focusable, onClick }) => {
let disabled = false;
let requiredMicroSDeckVer = '';
+
if (filterType === 'sd card') {
disabled = !MicroSDeckInterop.isInstallOk();
const [major, minor, patch] = microSDeckLibVersion.split(/[.+-]/, 3);
+
if (+major > 0) requiredMicroSDeckVer = major + '.x.x';
if (+major === 0) requiredMicroSDeckVer = `0.${minor}.${patch}`;
}
+
const canFocus = focusable && !disabled;
return (
@@ -73,9 +76,14 @@ const FilterSelectElement: VFC = ({ filterType, focusable,
className={`${achievementClasses.AchievementListItemBase} ${disabled && "entry-disabled"}`}
style={{ display: "flex", flexDirection: "column", padding: "0.5em", height: "60px" }}
>
-
- {capitalizeEachWord(filterType)}
- {filterType === 'sd card' &&
{`requires MicroSDeck ${requiredMicroSDeckVer}`}}
+
+
+ {createElement(FilterIcons[filterType], { size: '.8em' })}
+
+
+ {capitalizeEachWord(filterType)}
+
+ {filterType === 'sd card' &&
{`requires MicroSDeck ${requiredMicroSDeckVer}`}}
{FilterDescriptions[filterType]}
diff --git a/src/components/filters/Filters.tsx b/src/components/filters/Filters.tsx
index 5427a0c..85f22b1 100644
--- a/src/components/filters/Filters.tsx
+++ b/src/components/filters/Filters.tsx
@@ -1,7 +1,14 @@
+import { IconType } from "react-icons/lib";
import { MicroSDeckInterop } from '../../lib/controllers/MicroSDeckInterop';
import { PluginController } from "../../lib/controllers/PluginController";
import { DateIncludes, DateObj } from '../generic/DatePickers';
import { STEAM_FEATURES_ID_MAP } from "./SteamFeatures";
+import { FaCheckCircle, FaHdd, FaSdCard, FaUserFriends } from "react-icons/fa";
+import { IoGrid } from "react-icons/io5";
+import { SiSteamdeck } from "react-icons/si";
+import { FaAward, FaBan, FaCalendarDays, FaCloudArrowDown, FaCompactDisc, FaListCheck, FaPlay, FaRegClock, FaSteam, FaTags } from "react-icons/fa6";
+import { BsClockHistory, BsRegex } from "react-icons/bs";
+import { LuCombine } from "react-icons/lu";
export type FilterType = 'collection' | 'installed' | 'regex' | 'friends' | 'tags' | 'whitelist' | 'blacklist' | 'merge' | 'platform' | 'deck compatibility' | 'review score' | 'time played' | 'size on disk' | 'release date' | 'last played' | 'demo' | 'streamable' | 'steam features' | 'sd card';
@@ -75,7 +82,7 @@ type FilterFunction = (params: FilterParams, appOverview: SteamAppOv
* Checking and settings defaults in component is unnecessary
*/
export const FilterDefaultParams: { [key in FilterType]: FilterParams } = {
- "collection": { id: "", name: "" },
+ "collection": { id: "favorite", name: "Favorites" },
"installed": { installed: true },
"regex": { regex: "" },
"friends": { friends: [], mode: 'and' },
@@ -118,7 +125,32 @@ export const FilterDescriptions: { [filterType in FilterType]: string } = {
demo: "Selects apps that are/aren't demos.",
streamable: "Selects apps that can/can't be streamed from another computer.",
"steam features": "Selects apps that support specific Steam Features.",
- "sd card": "Selects apps that are present on the inserted/specific MicroSD Card"
+ "sd card": "Selects apps that are present on the inserted/specific MicroSD Card."
+}
+
+/**
+ * Dictionary of icons for each filter.
+ */
+export const FilterIcons: { [filterType in FilterType]: IconType } = {
+ collection: IoGrid,
+ installed: FaPlay,
+ regex: BsRegex,
+ friends: FaUserFriends,
+ tags: FaTags,
+ whitelist: FaCheckCircle,
+ blacklist: FaBan,
+ merge: LuCombine,
+ platform: FaSteam,
+ "deck compatibility": SiSteamdeck,
+ "review score": FaAward,
+ "time played": FaRegClock,
+ "size on disk": FaHdd,
+ "release date": FaCalendarDays,
+ "last played": BsClockHistory,
+ demo: FaCompactDisc,
+ streamable: FaCloudArrowDown,
+ "steam features": FaListCheck,
+ "sd card": FaSdCard
}
diff --git a/src/components/generic/ScrollableWindow.tsx b/src/components/generic/ScrollableWindow.tsx
new file mode 100644
index 0000000..87981c4
--- /dev/null
+++ b/src/components/generic/ScrollableWindow.tsx
@@ -0,0 +1,66 @@
+import { GamepadButton, gamepadDialogClasses, scrollPanelClasses } from 'decky-frontend-lib';
+import { FC, Fragment, useRef } from 'react';
+import { ModalPosition, Panel, ScrollPanelGroup } from '../docs/Scrollable';
+import { useIsOverflowing } from '../../hooks/useIsOverflowing';
+
+export interface ScrollableWindowProps {
+ height: string;
+ fadeAmount: string;
+ scrollBarWidth?: string;
+}
+export const ScrollableWindow: FC = ({ height, fadeAmount, scrollBarWidth, children }) => {
+ const barWidth = scrollBarWidth === undefined || scrollBarWidth === '' ? '4px' : scrollBarWidth;
+
+ const scrollPanelRef = useRef();
+ const isOverflowing = useIsOverflowing(scrollPanelRef);
+
+ const panel = (
+
+
+ {children}
+
+
+ );
+
+ return (
+ <>
+
+
+ {isOverflowing ? (
+
+ {panel}
+
+ ) : (
+
+ {panel}
+
+ )}
+
+ >
+ );
+};
diff --git a/src/components/modals/EditMergeFilterModal.tsx b/src/components/modals/EditMergeFilterModal.tsx
index ba090b8..a1f54f9 100644
--- a/src/components/modals/EditMergeFilterModal.tsx
+++ b/src/components/modals/EditMergeFilterModal.tsx
@@ -2,7 +2,7 @@ import { ConfirmModal } from "decky-frontend-lib";
import { VFC, useState, Fragment, useEffect } from "react";
import { ModalStyles } from "../styles/ModalStyles";
import { FiltersPanel } from "../filters/FiltersPanel";
-import { TabFilterSettings, FilterType } from "../filters/Filters";
+import { TabFilterSettings, FilterType, FilterDefaultParams } from "../filters/Filters";
import { isValidParams } from "../filters/Filters";
import { PythonInterop } from "../../lib/controllers/PythonInterop";
@@ -40,7 +40,7 @@ export const EditMergeFilterModal: VFC = ({ closeModa
updatedFilters.push({
type: "collection",
inverted: false,
- params: { id: "", name: "" }
+ params: FilterDefaultParams.collection
});
setGroupFilters(updatedFilters);
diff --git a/src/components/modals/EditTabModal.tsx b/src/components/modals/EditTabModal.tsx
index f2c9555..e4bbe09 100644
--- a/src/components/modals/EditTabModal.tsx
+++ b/src/components/modals/EditTabModal.tsx
@@ -10,7 +10,7 @@ import {
showModal
} from "decky-frontend-lib";
import { useState, VFC, useEffect, Fragment } from "react";
-import { FilterType, TabFilterSettings, isValidParams } from "../filters/Filters";
+import { FilterDefaultParams, FilterType, TabFilterSettings, isValidParams } from "../filters/Filters";
import { PythonInterop } from "../../lib/controllers/PythonInterop";
import { TabMasterContextProvider } from "../../state/TabMasterContext";
import { TabMasterManager } from "../../state/TabMasterManager";
@@ -47,7 +47,7 @@ export const EditTabModal: VFC = ({ closeModal, onConfirm, ta
const [patchInput, setPatchInput] = useState(true);
const [autoHide, setAutoHide] = useState(_autoHide);
- const nameInputElement = ;
+ const nameInputElement = ;
//reference to input field class component instance, which has a focus method
let inputComponentInstance: any;
@@ -99,7 +99,7 @@ export const EditTabModal: VFC = ({ closeModal, onConfirm, ta
updatedFilters.push({
type: "collection",
inverted: false,
- params: { id: "", name: "" }
+ params: FilterDefaultParams.collection
});
setTopLevelFilters(updatedFilters);
}
diff --git a/src/components/modals/ListSearchModal.tsx b/src/components/modals/ListSearchModal.tsx
index 28013cd..c127b45 100644
--- a/src/components/modals/ListSearchModal.tsx
+++ b/src/components/modals/ListSearchModal.tsx
@@ -9,13 +9,14 @@ import {
SingleDropdownOption,
TextField
} from "decky-frontend-lib";
-import { VFC, useEffect, useState } from "react";
+import { VFC, useEffect, useMemo, useState } from "react";
import { IconType } from "react-icons/lib";
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import { FaMagnifyingGlass } from "react-icons/fa6";
import { BaseModalProps, CustomDropdown } from "../generic/CustomDropdown";
import { ListSearchModalStyles } from "../styles/ListSearchModalStyles";
+import { BiSolidDownArrow, BiSolidUpArrow } from "react-icons/bi";
export type ListSearchModalProps = {
rgOptions: SingleDropdownOption[],
@@ -33,12 +34,28 @@ const iconStyles = {
export const ListSearchModal: VFC = ({ rgOptions: list, entryLabel, determineEntryIcon, onSelectOption, closeModal }: ListSearchModalProps) => {
const [query, setQuery] = useState("");
const [filteredList, setFilteredList] = useState(list);
+ const [renderTopArrow, setRenderTopArrow] = useState(false);
+ const [renderBottomArrow, setRenderBottomArrow] = useState(true);
useEffect(() => {
setFilteredList(list.filter((entry) => (entry.label as string).toLowerCase().includes(query.toLowerCase())));
}, [query]);
- const ListEntry = ({ index, style }: { index: number, style: any}) => {
+ function onItemsRendered({ visibleStartIndex, visibleStopIndex }: { visibleStartIndex: number, visibleStopIndex: number }) {
+ if (!renderTopArrow && visibleStartIndex !== 0) {
+ setRenderTopArrow(true);
+ } else if (renderTopArrow && visibleStartIndex === 0) {
+ setRenderTopArrow(false);
+ }
+
+ if (!renderBottomArrow && visibleStopIndex !== filteredList.length - 1) {
+ setRenderBottomArrow(true);
+ } else if (renderBottomArrow && visibleStopIndex === filteredList.length - 1) {
+ setRenderBottomArrow(false);
+ }
+ }
+
+ const ListEntry = useMemo<(props: { index: number, style: any}) => JSX.Element >(() => ({ index, style }) => {
const EntryIcon = determineEntryIcon(filteredList[index]);
return (
@@ -57,7 +74,7 @@ export const ListSearchModal: VFC = ({ rgOptions: list, en
);
- };
+ }, [filteredList]);
return (
@@ -84,6 +101,7 @@ export const ListSearchModal: VFC
= ({ rgOptions: list, en
{ setQuery(e.target.value); }}
style={{ height: "100%" }}
/>
@@ -91,25 +109,34 @@ export const ListSearchModal: VFC = ({ rgOptions: list, en
-
-
-
- {/* @ts-ignore */}
- {({ height, width }) => (
-
- {ListEntry}
-
- )}
-
-
-
+
+
+ {renderTopArrow && }
+
+
+
+
+ {/* @ts-ignore */}
+ {({ height, width }) => (
+
+ {ListEntry}
+
+ )}
+
+
+
+
+ {renderBottomArrow && }
+
+
@@ -117,7 +144,7 @@ export const ListSearchModal: VFC = ({ rgOptions: list, en
);
};
-export type ListSearchTrigger = {
+export type ListSearchTriggerProps = {
entryLabel: string,
labelOverride: string,
options: SingleDropdownOption[],
@@ -127,7 +154,7 @@ export type ListSearchTrigger = {
disabled: boolean
}
-export function ListSearchTrigger({ entryLabel, labelOverride, options, onChange, TriggerIcon, determineEntryIcon, disabled }: ListSearchTrigger) {
+export function ListSearchTrigger({ entryLabel, labelOverride, options, onChange, TriggerIcon, determineEntryIcon, disabled }: ListSearchTriggerProps) {
const ModalWrapper: VFC = ({ onSelectOption, rgOptions, closeModal }: BaseModalProps) => {
return
}
@@ -143,3 +170,34 @@ export function ListSearchTrigger({ entryLabel, labelOverride, options, onChange
/>
);
}
+
+export type ListSearchDropdownProps = {
+ entryLabel: string,
+ rgOptions: SingleDropdownOption[],
+ selectedOption: any,
+ onChange: (option: SingleDropdownOption) => void,
+ TriggerIcon: IconType,
+ determineEntryIcon: (entry?: any) => IconType,
+ disabled?: boolean
+}
+
+export function ListSearchDropdown({ entryLabel, rgOptions, selectedOption, onChange, TriggerIcon, determineEntryIcon, disabled }: ListSearchDropdownProps) {
+ const [selected, setSelected] = useState(rgOptions.find((option: SingleDropdownOption) => option.data === selectedOption)!);
+
+ function onChangeWrapper(data: SingleDropdownOption) {
+ setSelected(data);
+ onChange(data);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/modals/TabProfileModals.tsx b/src/components/modals/TabProfileModals.tsx
new file mode 100644
index 0000000..619a91c
--- /dev/null
+++ b/src/components/modals/TabProfileModals.tsx
@@ -0,0 +1,131 @@
+import { ConfirmModal, Field, TextField, quickAccessControlsClasses } from 'decky-frontend-lib';
+import { VFC, useState, Fragment, FC } from 'react';
+import { TabMasterManager } from '../../state/TabMasterManager';
+import { TabMasterContextProvider } from "../../state/TabMasterContext";
+import { TabProfileModalStyles } from "../styles/TabProfileModalStyles";
+import { TabListLabel } from '../TabListLabel';
+import { ScrollableWindow } from '../generic/ScrollableWindow';
+import { DestructiveModal } from '../generic/DestructiveModal';
+
+export interface CreateTabProfileModalProps {
+ tabMasterManager: TabMasterManager,
+ closeModal?: () => void,
+}
+
+export const CreateTabProfileModal: VFC = ({ tabMasterManager, closeModal }) => {
+ const [name, setName] = useState('');
+ const visibleTabs = tabMasterManager.getTabs().visibleTabsList;
+
+ function onNameChange(e: React.ChangeEvent) {
+ setName(e?.target.value);
+ }
+
+ return (
+
+
+
+
{
+ tabMasterManager.tabProfileManager?.write(name, visibleTabs.map(tabContainer => tabContainer.id));
+ closeModal!();
+ }}
+ onCancel={() => closeModal!()}
+ >
+
+
+
+ Profile Name
+
+
+ >
+ } />
+
+
+
+
+ {visibleTabs.map(tabContainer =>
+
+
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export interface OverwriteTabProfileModalProps extends CreateTabProfileModalProps {
+ profileName: string;
+}
+
+export const OverwriteTabProfileModal: VFC = ({ profileName, tabMasterManager, closeModal }) => {
+ const { visibleTabsList, tabsMap } = tabMasterManager.getTabs();
+ const existingTabs = tabMasterManager.tabProfileManager!.tabProfiles[profileName].map(tabId => tabsMap.get(tabId));
+
+ return (
+
+
+
+
{
+ tabMasterManager.tabProfileManager?.write(profileName, visibleTabsList.map(tabContainer => tabContainer.id));
+ closeModal!();
+ }}
+ onCancel={() => closeModal!()}
+ >
+
+
+
+ New Tabs
+
+
+ Existing Tabs
+
+
+
+
+
+
+
+ {visibleTabsList.map(tabContainer =>
+
+
+
+ )}
+
+
+ {existingTabs.map(tabContainer =>
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+const TabItem: FC<{}> = ({ children }) => {
+
+ return (
+ <>
+
+ {children}
+
+
+ >
+ );
+};
+
+
diff --git a/src/components/multi-selects/ModeMultiSelect.tsx b/src/components/multi-selects/ModeMultiSelect.tsx
index 3e65b43..0021339 100644
--- a/src/components/multi-selects/ModeMultiSelect.tsx
+++ b/src/components/multi-selects/ModeMultiSelect.tsx
@@ -90,7 +90,7 @@ export const ModeMultiSelect:VFC = ({ options, selected, f
labelOverride={dropdownSelected.label!}
disabled={available.length == 0 || (!!maxOptions && selected.length == maxOptions)}
TriggerIcon={TriggerIcon}
- determineEntryIcon={(entry) => { return determineEntryIcon ? determineEntryIcon(entry) : EntryIcon}}
+ determineEntryIcon={(entry) => { return (determineEntryIcon ? determineEntryIcon(entry) : EntryIcon) as IconType }}
/>
= ({}) => {
background: radial-gradient(155.42% 100% at 0% 0%, #060a0e 0 0%, #0e141b 100%);
}
- .tab-master-filter-select .gpfocuswithin .${achievementClasses.AchievementListItemBase} {
- background: #767a8773;
- }
-
.tab-master-filter-select .entry-label {
font-size: 22px;
text-align: initial;
@@ -124,6 +120,7 @@ export const FilterSelectStyles: VFC<{}> = ({}) => {
.tab-master-filter-select .entry-disabled {
color: #92939B;
+ background-color: #20222996;
}
.tab-master-filter-select .entry-desc {
diff --git a/src/components/styles/ListSearchModalStyles.tsx b/src/components/styles/ListSearchModalStyles.tsx
index 9aab61d..d1049ec 100644
--- a/src/components/styles/ListSearchModalStyles.tsx
+++ b/src/components/styles/ListSearchModalStyles.tsx
@@ -18,6 +18,44 @@ export const ListSearchModalStyles: VFC<{}> = ({}) => {
.tab-master-list-search-modal .${gamepadDialogClasses.ModalPosition} > .${gamepadDialogClasses.GamepadDialogContent} {
background: radial-gradient(155.42% 100% at 0% 0%, #060a0e 0 0%, #0e141b 100%);
}
+
+ @keyframes tab-master-arrow-bounce-up {
+ 0% { transform: translateY(1px) }
+ 50% { transform: translateY(-2px) }
+ 100% { transform: translateY(1px) }
+ }
+
+ @keyframes tab-master-arrow-bounce-down {
+ 0% { transform: translateY(-1px) }
+ 50% { transform: translateY(2px) }
+ 100% { transform: translateY(-1px) }
+ }
+
+ .tab-master-list-search-modal .more-above-arrow {
+ position: absolute;
+ top: 14px;
+
+ display: flex;
+ justify-content: center;
+ width: 100%;
+
+ transition: visibility 0.2s ease-in-out;
+
+ animation: tab-master-arrow-bounce-up 2.7s infinite ease-in-out;
+ }
+
+ .tab-master-list-search-modal .more-below-arrow {
+ position: absolute;
+ bottom: -16px;
+
+ display: flex;
+ justify-content: center;
+ width: 100%;
+
+ transition: visibility 0.2s ease-in-out;
+
+ animation: tab-master-arrow-bounce-down 2.7s infinite ease-in-out;
+ }
`}
);
}
diff --git a/src/components/styles/ModalStyles.tsx b/src/components/styles/ModalStyles.tsx
index 5110713..8f6dbdc 100644
--- a/src/components/styles/ModalStyles.tsx
+++ b/src/components/styles/ModalStyles.tsx
@@ -98,13 +98,6 @@ export const ModalStyles: VFC<{}> = ({}) => {
color: #a9a9a9;
}
- /* merge entries */
- .tab-master-modal-scope .merge-filter-entries .merge-filter-entry-container {
- margin: 5px;
- display: flex;
- justify-content: space-between;
- }
-
.autohide-toggle-container .${gamepadDialogClasses.Field} {
padding: 10px calc(28px + 1.4vw);
}
diff --git a/src/components/styles/TabProfileModalStyles.tsx b/src/components/styles/TabProfileModalStyles.tsx
new file mode 100644
index 0000000..21d0862
--- /dev/null
+++ b/src/components/styles/TabProfileModalStyles.tsx
@@ -0,0 +1,56 @@
+import { gamepadDialogClasses } from "decky-frontend-lib";
+import { VFC } from "react";
+
+// New modal background should be "radial-gradient(155.42% 100% at 0% 0%, #060a0e 0 0%, #0e141b 100%)"
+
+/**
+ * CSS styling for TabMaster's Tab profile modals.
+ */
+export const TabProfileModalStyles: VFC<{}> = ({}) => {
+ return (
+
+ );
+}
diff --git a/src/hooks/useIsOverflowing.tsx b/src/hooks/useIsOverflowing.tsx
new file mode 100644
index 0000000..aba924e
--- /dev/null
+++ b/src/hooks/useIsOverflowing.tsx
@@ -0,0 +1,19 @@
+import { MutableRefObject, useLayoutEffect, useState } from 'react';
+
+export const useIsOverflowing = (ref: MutableRefObject) => {
+ const [isOverflow, setIsOverflow] = useState(false);
+
+ useLayoutEffect(() => {
+ const { current } = ref;
+ const trigger = () => {
+ const hasOverflow = current!.scrollHeight > current!.clientHeight;
+ setIsOverflow(hasOverflow);
+ };
+
+ if (current) {
+ trigger();
+ }
+ }, [ref]);
+
+ return isOverflow;
+};
diff --git a/src/index.tsx b/src/index.tsx
index 237b3c7..7ee7ee5 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,46 +1,26 @@
import {
- ButtonItem,
definePlugin,
- DialogButton,
- Field,
- Focusable,
- Navigation,
- PanelSection,
- ReorderableEntry,
- ReorderableList,
RoutePatch,
ServerAPI,
- showContextMenu,
- SidebarNavigation,
- staticClasses,
} from "decky-frontend-lib";
-import { VFC, ReactNode, useState } from "react";
import { TbLayoutNavbarExpand } from "react-icons/tb";
-import { FaCircleExclamation } from "react-icons/fa6";
-import { PiListPlusBold } from "react-icons/pi";
-import { MdNumbers } from "react-icons/md";
import { PluginController } from "./lib/controllers/PluginController";
import { PythonInterop } from "./lib/controllers/PythonInterop";
-import { TabMasterContextProvider, useTabMasterContext } from "./state/TabMasterContext";
+import { TabMasterContextProvider } from "./state/TabMasterContext";
import { TabMasterManager } from "./state/TabMasterManager";
import { patchLibrary } from "./patches/LibraryPatch";
import { patchSettings } from "./patches/SettingsPatch";
-import { QamStyles } from "./components/styles/QamStyles";
-import { showModalNewTab } from "./components/modals/EditTabModal";
-import { TabActionsButton } from "./components/TabActions";
import { LogController } from "./lib/controllers/LogController";
-import { DocPage } from "./components/docs/DocsPage";
-import { PresetMenu } from './components/menus/PresetMenu';
-import { TabListLabel } from './components/TabListLabel';
import { MicroSDeck } from "@cebbinghaus/microsdeck";
-import { MicroSDeckInstallState, MicroSDeckInterop, microSDeckLibVersion } from './lib/controllers/MicroSDeckInterop';
-import { MicroSDeckNotice } from './components/MicroSDeckNotice';
-import { CustomTabContainer } from './components/CustomTabContainer';
+import { MicroSDeckInterop } from './lib/controllers/MicroSDeckInterop';
+import { QuickAccessContent, QuickAccessTitleView } from "./components/QuickAccessContent";
+import { DocsRouter } from "./components/docs/DocsRouter";
+import { Fragment } from 'react';
declare global {
let DeckyPluginLoader: { pluginReloadQueue: { name: string; version?: string; }[]; };
@@ -55,186 +35,6 @@ declare global {
let settingsStore: SettingsStore;
}
-export type TabIdEntryType = {
- id: string;
-};
-
-interface TabEntryInteractablesProps {
- entry: ReorderableEntry;
-}
-
-/**
- * The Quick Access Menu content for TabMaster.
- */
-const Content: VFC<{}> = ({ }) => {
- const [microSDeckNoticeHidden, setMicroSDeckNoticeHidden] = useState(MicroSDeckInterop.noticeHidden);
- const { visibleTabsList, hiddenTabsList, tabsMap, tabMasterManager } = useTabMasterContext();
-
- const microSDeckInstallState = MicroSDeckInterop.getInstallState();
- const isMicroSDeckInstalled = microSDeckInstallState === MicroSDeckInstallState['good'];
- const hasSdTabs = !!visibleTabsList.find(tabContainer => (tabContainer as CustomTabContainer).dependsOnMicroSDeck);
-
- function TabEntryInteractables({ entry }: TabEntryInteractablesProps) {
- const tabContainer = tabsMap.get(entry.data!.id)!;
- return ();
- }
-
- const entries = visibleTabsList.map((tabContainer) => {
- return {
- label: ,
- position: tabContainer.position,
- data: { id: tabContainer.id }
- };
- });
-
- return (
-
- {LogController.errorFlag &&
-
-
- Tab Master encountered an error
-
-
-
}
- {hasSdTabs && !isMicroSDeckInstalled && !microSDeckNoticeHidden && (
-
-
-
-
- {
- MicroSDeckInterop.noticeHidden = true;
- setMicroSDeckNoticeHidden(true);
- }}
- >
- Hide Notice
-
-
-
-
- )}
-
-
{ Navigation.CloseSideMenus(); Navigation.Navigate("/tab-master-docs"); }}>
-
- Here you can add, re-order, or remove tabs from the library.
-
-
-
-
- showModalNewTab(tabMasterManager)} onOKActionDescription={'Add Tab'}>
- Add Tab
-
-
- {tabMasterManager.hasSettingsLoaded &&
-
- showContextMenu()}
- >
-
-
- }
-
-
-
-
- {tabMasterManager.hasSettingsLoaded ? (
-
- entries={entries}
- interactables={TabEntryInteractables}
- onSave={(entries: ReorderableEntry[]) => {
- tabMasterManager.reorderTabs(entries.map(entry => entry.data!.id));
- }}
- />
- ) : (
-
- Loading...
-
- )}
-
-
-
- {
- hiddenTabsList.map(tabContainer =>
-
- }
- onClick={() => tabMasterManager.showTab(tabContainer.id)}
- onOKActionDescription="Unhide tab"
- >
- Show
-
-
- )
- }
-
- {hasSdTabs && !isMicroSDeckInstalled && microSDeckNoticeHidden && (
- { }}>
-
-
- )}
-
-
- );
-};
-
-type DocRouteEntry = {
- title: string,
- content: ReactNode,
- route: string,
- icon: ReactNode,
- hideTitle: boolean;
-};
-
-type DocRoutes = {
- [pageName: string]: DocRouteEntry;
-};
-
-type TabMasterDocsRouterProps = {
- docs: DocPages;
-};
-
-/**
- * The documentation pages router for TabMaster.
- */
-const TabMasterDocsRouter: VFC = ({ docs }) => {
- const docPages: DocRoutes = {};
- Object.entries(docs).map(([pageName, doc]) => {
- docPages[pageName] = {
- title: pageName,
- content: ,
- route: `/tab-master-docs/${pageName.toLowerCase().replace(/ /g, "-")}`,
- icon: ,
- hideTitle: true
- };
- });
-
- return (
-
- );
-};
-
export default definePlugin((serverAPI: ServerAPI) => {
let libraryPatch: RoutePatch;
@@ -252,25 +52,23 @@ export default definePlugin((serverAPI: ServerAPI) => {
settingsPatch = patchSettings(serverAPI, tabMasterManager);
});
- const onWakeUnregister = SteamClient.System.RegisterForOnResumeFromSuspend(PluginController.onWakeFromSleep.bind(PluginController)).unregister;
-
PythonInterop.getDocs().then((pages: DocPages | Error) => {
if (pages instanceof Error) {
LogController.error(pages);
} else {
serverAPI.routerHook.addRoute("/tab-master-docs", () => (
-
+
));
}
});
-
return {
- title: TabMaster
,
+ title: <>>,
+ titleView: ,
content:
-
+
,
icon: ,
onDismount: () => {
@@ -279,7 +77,6 @@ export default definePlugin((serverAPI: ServerAPI) => {
serverAPI.routerHook.removeRoute("/tab-master-docs");
loginUnregisterer.unregister();
- onWakeUnregister();
PluginController.dismount();
},
};
diff --git a/src/lib/controllers/MicroSDeckInterop.ts b/src/lib/controllers/MicroSDeckInterop.ts
index bc94c4a..8663c41 100644
--- a/src/lib/controllers/MicroSDeckInterop.ts
+++ b/src/lib/controllers/MicroSDeckInterop.ts
@@ -103,18 +103,22 @@ export class MicroSDeckInterop {
* @returns MicroSDeckInstallState
*/
private static checkVersion() {
- const [pluginVerMajor, pluginVerMinor, pluginVerPatch] = window.MicroSDeck!.Version.split(/[.+-]/, 3).map(str => +str);
- const [libVerMajor, libVerMinor, libVerPatch] = microSDeckLibVersion.split(/[.+-]/, 3).map(str => +str);
+ if (window.MicroSDeck?.Version) {
+ const [pluginVerMajor, pluginVerMinor, pluginVerPatch] = window.MicroSDeck!.Version.split(/[.+-]/, 3).map(str => +str);
+ const [libVerMajor, libVerMinor, libVerPatch] = microSDeckLibVersion.split(/[.+-]/, 3).map(str => +str);
- if (isNaN(pluginVerMajor) || isNaN(pluginVerMinor) || isNaN(pluginVerPatch) || isNaN(libVerMajor) || isNaN(libVerMinor) || isNaN(libVerPatch)) return MicroSDeckInstallState['ver unknown'];
- if (pluginVerMajor === 0 && libVerMajor === 0) {
- if (pluginVerMinor > libVerMinor || pluginVerPatch > libVerPatch) return MicroSDeckInstallState['ver too high'];
- if (pluginVerMinor < libVerMinor || pluginVerPatch < libVerPatch) return MicroSDeckInstallState['ver too low'];
+ if (isNaN(pluginVerMajor) || isNaN(pluginVerMinor) || isNaN(pluginVerPatch) || isNaN(libVerMajor) || isNaN(libVerMinor) || isNaN(libVerPatch)) return MicroSDeckInstallState['ver unknown'];
+ if (pluginVerMajor === 0 && libVerMajor === 0) {
+ if (pluginVerMinor > libVerMinor || pluginVerPatch > libVerPatch) return MicroSDeckInstallState['ver too high'];
+ if (pluginVerMinor < libVerMinor || pluginVerPatch < libVerPatch) return MicroSDeckInstallState['ver too low'];
+ return MicroSDeckInstallState['good'];
+ }
+
+ if (pluginVerMajor > libVerMajor) return MicroSDeckInstallState['ver too high'];
+ if (pluginVerMajor < libVerMajor) return MicroSDeckInstallState['ver too low'];
return MicroSDeckInstallState['good'];
+ } else {
+ return MicroSDeckInstallState['ver too low']; //* version is so old it doesn't have the Version prop.
}
-
- if (pluginVerMajor > libVerMajor) return MicroSDeckInstallState['ver too high'];
- if (pluginVerMajor < libVerMajor) return MicroSDeckInstallState['ver too low'];
- return MicroSDeckInstallState['good'];
}
}
diff --git a/src/lib/controllers/PluginController.tsx b/src/lib/controllers/PluginController.tsx
index 0cdb841..18c5ab9 100644
--- a/src/lib/controllers/PluginController.tsx
+++ b/src/lib/controllers/PluginController.tsx
@@ -46,6 +46,8 @@ export class PluginController {
private static steamController: SteamController;
+ private static onWakeSub: Unregisterer;
+
/**
* Sets the plugin's serverAPI.
* @param server The serverAPI to use.
@@ -79,6 +81,8 @@ export class PluginController {
*/
static async init(): Promise {
LogController.log("PluginController initialized.");
+
+ this.onWakeSub = this.steamController.registerForOnResumeFromSuspend(this.onWakeFromSleep.bind(this));
// @ts-ignore
return new Promise(async (resolve, reject) => {
@@ -121,7 +125,10 @@ export class PluginController {
* Function to run when the plugin dismounts.
*/
static dismount(): void {
+ if (this.onWakeSub) this.onWakeSub.unregister();
+
this.tabMasterManager.disposeReactions();
+
LogController.log("PluginController dismounted.");
}
}
diff --git a/src/lib/controllers/PythonInterop.ts b/src/lib/controllers/PythonInterop.ts
index 22c78e6..ddcfc56 100644
--- a/src/lib/controllers/PythonInterop.ts
+++ b/src/lib/controllers/PythonInterop.ts
@@ -1,5 +1,6 @@
import { ServerAPI } from "decky-frontend-lib";
import { validateTabs } from "../Utils";
+import { TabProfileDictionary } from '../../state/TabProfileManager';
/**
* Class for frontend -> backend communication.
@@ -135,8 +136,8 @@ export class PythonInterop {
}
/**
- * Gets the store tabs.
- * @returns A promise resolving to the store tabs.
+ * Gets the store tags.
+ * @returns A promise resolving to the store tags.
*/
static async getTags(): Promise {
let result = await PythonInterop.serverAPI.callPluginMethod<{}, TagResponse[]>("get_tags", {});
@@ -180,6 +181,20 @@ export class PythonInterop {
}
}
+ /**
+ * Gets the user's tab profiles.
+ * @returns A promise resolving the user's tab profiles.
+ */
+ static async getTabProfiles(): Promise {
+ let result = await PythonInterop.serverAPI.callPluginMethod<{}, TabProfileDictionary>("get_tab_profiles", {});
+
+ if (result.success) {
+ return result.result;
+ } else {
+ return new Error(result.result);
+ };
+ }
+
/**
* Sets the plugin's tabs.
* @param tabs The plugin's tabsDictionary.
@@ -251,6 +266,21 @@ export class PythonInterop {
};
}
+ /**
+ * Sets the user's tab profiles.
+ * @param tabProfiles The tab profiles.
+ * @returns A promise resolving to whether or not the tab profiles were successfully set.
+ */
+ static async setTabProfiles(tabProfiles: TabProfileDictionary): Promise {
+ let result = await PythonInterop.serverAPI.callPluginMethod<{ tab_profiles: TabProfileDictionary }, void>("set_tab_profiles", { tab_profiles: tabProfiles });
+
+ if (result.success) {
+ return result.result;
+ } else {
+ return new Error(result.result);
+ };
+ }
+
/**
* Shows a toast message.
* @param title The title of the toast.
diff --git a/src/lib/controllers/SteamController.ts b/src/lib/controllers/SteamController.ts
index 666918a..fe2c6d6 100644
--- a/src/lib/controllers/SteamController.ts
+++ b/src/lib/controllers/SteamController.ts
@@ -69,6 +69,15 @@ export class SteamController {
})) ?? false;
}
+ /**
+ * Register a function for when the Steamdeck resumes from sleep.
+ * @param callback The callback to register.
+ * @returns A function that unsubscribes the callback.
+ */
+ registerForOnResumeFromSuspend(callback: () => void): Unregisterer {
+ return SteamClient.System.RegisterForOnResumeFromSuspend(callback);
+ }
+
/**
* Gets the localized tags from a list of ids.
* @param tags The list of tag ids.
diff --git a/src/patches/LibraryPatch.tsx b/src/patches/LibraryPatch.tsx
index 929db98..cb76565 100644
--- a/src/patches/LibraryPatch.tsx
+++ b/src/patches/LibraryPatch.tsx
@@ -11,7 +11,7 @@ import { ReactElement, useEffect, useState } from "react";
import { TabMasterManager } from "../state/TabMasterManager";
import { CustomTabContainer } from "../components/CustomTabContainer";
import { LogController } from "../lib/controllers/LogController";
-import { LibraryMenu } from '../components/menus/LibraryMenu';
+import { LibraryMenu } from '../components/context-menus/LibraryMenu';
import { MicroSDeckInterop } from '../lib/controllers/MicroSDeckInterop';
/**
@@ -87,11 +87,7 @@ export const patchLibrary = (serverAPI: ServerAPI, tabMasterManager: TabMasterMa
pacthedTabs = tablist.flatMap((tabContainer) => {
if (tabContainer.filters) {
const footer = { ...(tabTemplate!.footer ?? {}), onMenuButton: getShowMenu(tabContainer.id, tabMasterManager), onMenuActionDescription: 'Tab Master' };
-
- //if MicroSDeck isn't installed don't display any tabs that depend on it; return empty array for flat map
- if (!isMicroSDeckInstalled && (tabContainer as CustomTabContainer).dependsOnMicroSDeck) return [];
- if ((tabContainer as CustomTabContainer).autoHide && (tabContainer as CustomTabContainer).collection.visibleApps.length === 0) return [];
- return (tabContainer as CustomTabContainer).getActualTab(tabContentComponent, sortingProps, footer, collectionsAppFilterGamepad);
+ return (tabContainer as CustomTabContainer).getActualTab(tabContentComponent, sortingProps, footer, collectionsAppFilterGamepad, isMicroSDeckInstalled) || [];
} else {
return tabs.find(actualTab => {
if (actualTab.id === tabContainer.id) {
diff --git a/src/presets/presets.ts b/src/presets/presets.ts
index 1fe6ca6..af48c15 100644
--- a/src/presets/presets.ts
+++ b/src/presets/presets.ts
@@ -10,10 +10,12 @@ type TabPreset = {
const presetDefines = {
collection: (collectionId: string, collectionName: string) => {
+ let include = IncludeCategories.games;
+ if (collectionId === 'hidden') include |= (IncludeCategories.music | IncludeCategories.software | IncludeCategories.hidden);
return {
filters: [{ type: 'collection', inverted: false, params: { id: collectionId, name: collectionName } }],
filtersMode: 'and',
- categoriesToInclude: IncludeCategories.games
+ categoriesToInclude: include
};
},
diff --git a/src/state/TabMasterManager.tsx b/src/state/TabMasterManager.tsx
index 3ad9aac..8e34530 100644
--- a/src/state/TabMasterManager.tsx
+++ b/src/state/TabMasterManager.tsx
@@ -9,6 +9,7 @@ import { LogController } from "../lib/controllers/LogController";
import { PresetName, PresetOptions, getPreset } from '../presets/presets';
import { MicroSDeckInterop } from '../lib/controllers/MicroSDeckInterop';
import { TabErrorController } from '../lib/controllers/TabErrorController';
+import { TabProfileManager } from './TabProfileManager';
/**
* Converts a list of filters into a 1D array.
@@ -66,6 +67,8 @@ export class TabMasterManager {
private collectionRemoveReaction: IReactionDisposer | undefined;
+ public tabProfileManager: TabProfileManager | undefined;
+
/**
* Creates a new TabMasterManager.
*/
@@ -218,14 +221,29 @@ export class TabMasterManager {
* @param storeTagLocalizationMap The store tag localization map.
*/
private storeTagReaction(storeTagLocalizationMap: StoreTagLocalizationMap) {
- this.allStoreTags = Array.from(storeTagLocalizationMap._data.entries()).map(([tag, entry]) => {
- return {
- tag: tag,
- string: entry.value
- };
- });
+ let tagLocalizationMap = storeTagLocalizationMap._data;
+ if (!tagLocalizationMap && storeTagLocalizationMap.data_) {
+ tagLocalizationMap = storeTagLocalizationMap.data_
+ }
+
+ if (tagLocalizationMap) {
+ const tagEntriesArray = Array.from(tagLocalizationMap.entries());
+
+ if (tagEntriesArray[0][1].value || tagEntriesArray[0][1].value_) {
+ this.allStoreTags = tagEntriesArray.map(([tag, entry]) => {
+ return {
+ tag: tag,
+ string: entry.value ?? entry.value_
+ };
+ });
- PythonInterop.setTags(this.allStoreTags);
+ PythonInterop.setTags(this.allStoreTags);
+ } else {
+ LogController.error("Failed to get store tags. Both entry.value and entry.value_ were undefined");
+ }
+ } else {
+ LogController.error("Failed to get store tags. Both _data and data_ were undefined");
+ }
}
/**
@@ -429,6 +447,8 @@ export class TabMasterManager {
}
}
this.tabsMap.delete(tabId);
+ this.tabProfileManager?.onDeleteTab(tabId);
+ if (!this.tabProfileManager) LogController.error('Attempted to delete a tab before TabProfileManager has been initialized.', 'This should not be possible.');
this.updateAndSave();
}
@@ -474,15 +494,10 @@ export class TabMasterManager {
}
}
-
-
/**
- * Loads the user's tabs from the backend.
+ * Other async load calls that don't need to be waited for when starting the plugin
*/
- loadTabs = async () => {
- this.initReactions();
- const settings = await PythonInterop.getTabs();
- //* We don't need to wait for these, since if we get the store ones, we don't care about them
+ asyncLoadOther() {
PythonInterop.getTags().then((res: TagResponse[] | Error) => {
if (res instanceof Error) {
LogController.log("TabMaster couldn't load tags settings");
@@ -513,13 +528,30 @@ export class TabMasterManager {
}
}
});
+ }
+
+ /**
+ * Loads the user's tabs from the backend.
+ */
+ loadTabs = async () => {
+ this.initReactions();
+ const settings = await PythonInterop.getTabs();
+ const profiles = await PythonInterop.getTabProfiles();
+
+ this.asyncLoadOther();
if (settings instanceof Error) {
LogController.log("TabMaster couldn't load tab settings");
LogController.error(settings.message);
return;
}
+ if (profiles instanceof Error) {
+ LogController.log("TabMaster couldn't load tab profiles");
+ LogController.error(profiles.message);
+ return;
+ }
+ this.tabProfileManager = new TabProfileManager(profiles);
TabErrorController.validateSettingsOnLoad((Object.keys(settings).length > 0) ? settings : defaultTabsSettings, this, this.finishLoadingTabs.bind(this));
};
diff --git a/src/state/TabProfileManager.tsx b/src/state/TabProfileManager.tsx
new file mode 100644
index 0000000..ac6b631
--- /dev/null
+++ b/src/state/TabProfileManager.tsx
@@ -0,0 +1,62 @@
+import { PythonInterop } from '../lib/controllers/PythonInterop';
+import { TabMasterManager } from './TabMasterManager';
+
+export type TabProfileDictionary = {
+ [name: string]: string[]; //array of ordered tab ids
+};
+
+export class TabProfileManager {
+ tabProfiles: TabProfileDictionary;
+
+ /**
+ * Creates a new TabProfileManager.
+ * @param tabProfiles The existing tab profiles the current user has.
+ */
+ constructor(tabProfiles: TabProfileDictionary) {
+ this.tabProfiles = tabProfiles;
+ }
+
+ /**
+ * Writes a tab profile.
+ * @param tabProfileName The name of the tab profile to write.
+ * @param tabIds The list of ids of the tabs that are included in this profile.
+ */
+ write(tabProfileName: string, tabIds: string[]) {
+ this.tabProfiles[tabProfileName] = tabIds;
+ this.save();
+ }
+
+ /**
+ * Applies a tab profile.
+ * @param tabProfileName The name of the tab profile to apply.
+ * @param tabMasterManager The plugin manager.
+ */
+ apply(tabProfileName: string, tabMasterManager: TabMasterManager) {
+ tabMasterManager.getTabs().tabsMap.forEach(tabContainer => tabContainer.position = -1);
+ tabMasterManager.reorderTabs(this.tabProfiles[tabProfileName]);
+ }
+
+ delete(tabProfileName: string) {
+ delete this.tabProfiles[tabProfileName];
+ this.save();
+ }
+
+ /**
+ * Removes tab from profiles when it has been deleted
+ * @param deletedId The tab id that is being deleted
+ */
+ onDeleteTab(deletedId: string) {
+ Object.values(this.tabProfiles).forEach(tabs => {
+ const deletedIndex = tabs.findIndex(tabId => tabId === deletedId);
+ if (deletedIndex > -1) tabs.splice(deletedIndex, 1);
+ });
+ this.save();
+ }
+
+ /**
+ * Saves all changes made to the tab profiles.
+ */
+ private save() {
+ PythonInterop.setTabProfiles(this.tabProfiles);
+ }
+}
diff --git a/src/types/stores/appStore.d.ts b/src/types/stores/appStore.d.ts
index 628890a..2298b8d 100644
--- a/src/types/stores/appStore.d.ts
+++ b/src/types/stores/appStore.d.ts
@@ -1,14 +1,22 @@
// Types for the global appStore
type StoreTagLocalizationEntry = {
- value: string //? This is the string of the tag
+ /**
+ * @deprecated Replaced by data_. Used before Dec 13 2023 on the stable Steam Client Channel, while it used MobX 5.x.x (now uses 6.x.x).
+ */
+ value: string,
+ value_: string //? This is the string of the tag
}
type StoreTagLocalizationMap = {
- _data: Map
+ /**
+ * @deprecated Replaced by data_. Used before Dec 13 2023 on the stable Steam Client Channel, while it used MobX 5.x.x (now uses 6.x.x).
+ */
+ _data?: Map
+ data_?: Map
}
type AppStore = {
GetAppOverviewByAppID: (appId: number) => SteamAppOverview | null;
m_mapStoreTagLocalization: StoreTagLocalizationMap
-}
\ No newline at end of file
+}