diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2d08c14 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": false, + "tabWidth": 2, + "semi": true, + "trailingComma": "es5", + "bracketSameLine": false +} diff --git a/README.md b/README.md index fc1a5a4..a717b0a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# SDH-CssLoader -Dynamically loads css off storage. Also reloads whenever the steam ui reloads +# CSS Loader +Dynamically loads CSS files from storage and reloads alongside Steam UI. -## How it works -The loader reads all folders in `/home/deck/homebrew/themes`. In every folder, it looks for a file called `theme.json`. This json file stores in which tab which css should be injected. An example theme can be found in the themes folder of this repository +# Overview +The loader reads all folders in `/home/deck/homebrew/themes`. In every folder, it reads a `theme.json` file that stores how the CSS should be injected. -[More information about creating themes, and publishing themes to the theme browser can be found here](https://github.com/suchmememanyskill/CssLoader-ThemeDb) \ No newline at end of file +[Information about creating and publishing themes can be found here](https://github.com/suchmememanyskill/CssLoader-ThemeDb) diff --git a/main.py b/main.py index c55689a..91b7ca3 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,12 @@ from logging import getLogger, basicConfig, INFO, DEBUG pluginManagerUtils = Utilities(None) -Initialized = False +Initialized = False +CSS_LOADER_VER = 2 +Logger = getLogger("CSS_LOADER") + +def Log(text : str): + Logger.info(text) def createDir(dirPath : str): if (path.exists(dirPath)): @@ -22,6 +27,9 @@ class Result: def __init__(self, success : bool, message : str = "Success"): self.success = success self.message = message + + if not self.success: + Log(f"Result failed! {message}") def raise_on_failure(self): if not self.success: @@ -42,12 +50,11 @@ def __init__(self, cssPath : str, tabs : List[str], theme): self.uuids[x] = [] async def load(self) -> Result: - self.theme.log("Inject.load") try: with open(self.cssPath, "r") as fp: self.css = fp.read() - self.theme.log(f"Loaded css at {self.cssPath}") + Log(f"Loaded css at {self.cssPath}") self.css = self.css.replace("\\", "\\\\").replace("`", "\\`") return Result(True) @@ -55,7 +62,6 @@ async def load(self) -> Result: return Result(False, str(e)) async def inject(self, tab : str = None) -> Result: - self.theme.log("Inject.inject") if (tab is None): for x in self.tabs: await self.inject(x) @@ -67,6 +73,7 @@ async def inject(self, tab : str = None) -> Result: if (len(self.uuids[tab]) > 0): await self.remove(tab) + self.enabled = True # In case the below code fails, it will never be re-injected unless it's still enabled if (self.css is None): result = await self.load() @@ -78,7 +85,7 @@ async def inject(self, tab : str = None) -> Result: if not res["success"]: return Result(False, str(res["result"])) - self.theme.log(f"+{str(res['result'])} @ {tab}") + Log(f"+{str(res['result'])} @ {tab}") self.uuids[tab].append(str(res["result"])) except Exception as e: return Result(False, str(e)) @@ -87,7 +94,6 @@ async def inject(self, tab : str = None) -> Result: return Result(True) async def remove(self, tab : str = None) -> Result: - self.theme.log("Inject.remove") if (tab is None): for x in self.tabs: await self.remove(x) @@ -102,7 +108,7 @@ async def remove(self, tab : str = None) -> Result: try: for x in self.uuids[tab]: - self.theme.log(f"-{x} @ {tab}") + Log(f"-{x} @ {tab}") res = await pluginManagerUtils.remove_css_from_tab(tab, x) #if not res["success"]: # return Result(False, res["result"]) @@ -120,6 +126,10 @@ def __init__(self, themePath : str, json : dict, configPath : str = None): self.name = json["name"] self.version = json["version"] if ("version" in json) else "v1.0" self.author = json["author"] if ("author" in json) else "" + self.require = int(json["manifest_version"]) if ("manifest_version" in json) else 1 + + if (CSS_LOADER_VER < self.require): + raise Exception("A newer version of the CssLoader is required to load this theme") self.patches = [] self.injects = [] @@ -131,27 +141,14 @@ def __init__(self, themePath : str, json : dict, configPath : str = None): self.enabled = False self.json = json - self.logobj = None - def log(self, text : str): - if self.logobj is not None: - self.logobj.info(text) - - async def load(self) -> Result: - self.log("Theme.load") if "inject" in self.json: - for x in self.json["inject"]: - self.injects.append(Inject(self.themePath + "/" + x, self.json["inject"][x], self)) + self.injects = [Inject(self.themePath + "/" + x, self.json["inject"][x], self) for x in self.json["inject"]] if "patches" in self.json: - for x in self.json["patches"]: - patch = ThemePatch(self, self.json["patches"][x], x) - result = await patch.load() - if not result.success: - return result - - self.patches.append(patch) - + self.patches = [ThemePatch(self, self.json["patches"][x], x) for x in self.json["patches"]] + + async def load(self) -> Result: if not path.exists(self.configJsonPath): return Result(True) @@ -179,7 +176,6 @@ async def load(self) -> Result: return Result(True) async def save(self) -> Result: - self.log("Theme.save") createDir(self.configPath) try: @@ -196,7 +192,7 @@ async def save(self) -> Result: return Result(True) async def inject(self) -> Result: - self.log(f"Injecting theme '{self.name}'") + Log(f"Injecting theme '{self.name}'") for x in self.injects: result = await x.inject() if not result.success: @@ -212,7 +208,7 @@ async def inject(self) -> Result: return Result(True) async def remove(self) -> Result: - self.log("Theme.remove") + Log(f"Removing theme '{self.name}'") for x in self.get_all_injects(): result = await x.remove() if not result.success: @@ -223,8 +219,6 @@ async def remove(self) -> Result: return Result(True) async def delete(self) -> Result: - self.log("Theme.delete") - if (self.bundled): return Result(False, "Can't delete a bundled theme") @@ -240,7 +234,6 @@ async def delete(self) -> Result: return Result(True) def get_all_injects(self) -> List[Inject]: - self.log("Theme.get_all_injects") injects = [] injects.extend(self.injects) for x in self.patches: @@ -256,6 +249,7 @@ def to_dict(self) -> dict: "enabled": self.enabled, "patches": [x.to_dict() for x in self.patches], "bundled": self.bundled, + "require": self.require, } @@ -264,37 +258,57 @@ def __init__(self, theme : Theme, json : dict, name : str): self.json = json self.name = name self.default = json["default"] + self.type = json["type"] if "type" in json else "dropdown" self.theme = theme self.value = self.default self.injects = [] self.options = {} - for x in json: - if (x == "default"): - continue + self.patchVersion = None + + if "values" in json: # Do we have a v2 or a v1 format? + self.patchVersion = 2 + for x in json["values"]: + self.options[x] = [] + else: + self.patchVersion = 1 + for x in json: + if (x == "default"): + continue - self.options[x] = [] + self.options[x] = [] + + if self.default not in self.options: + raise Exception(f"In patch '{self.name}', '{self.default}' does not exist as a patch option") + + self.load() def check_value(self): if (self.value not in self.options): self.value = self.default + + if (self.type not in ["dropdown", "checkbox", "slider"]): + self.type = "dropdown" + + if (self.type == "checkbox"): + if not ("No" in self.options and "Yes" in self.options): + self.type = "dropdown" - async def load(self) -> Result: - self.theme.log("ThemePatch.load") + def load(self): for x in self.options: - for y in self.json[x]: - inject = Inject(self.theme.themePath + "/" + y, self.json[x][y], self.theme) + data = self.json[x] if self.patchVersion == 1 else self.json["values"][x] + + for y in data: + inject = Inject(self.theme.themePath + "/" + y, data[y], self.theme) self.injects.append(inject) self.options[x].append(inject) - return Result(True) + self.check_value() async def inject(self) -> Result: self.check_value() - self.theme.log(f"Injecting patch '{self.name}' of theme '{self.theme.name}'") + Log(f"Injecting patch '{self.name}' of theme '{self.theme.name}'") for x in self.options[self.value]: - self.theme.log(x) result = await x.inject() - self.theme.log(result.message) if not result.success: return result @@ -302,7 +316,7 @@ async def inject(self) -> Result: async def remove(self) -> Result: self.check_value() - self.theme.log("ThemePatch.remove") + Log(f"Removing patch '{self.name}' of theme '{self.theme.name}'") for x in self.injects: result = await x.remove() if not result.success: @@ -315,12 +329,13 @@ def to_dict(self) -> dict: "name": self.name, "default": self.default, "value": self.value, - "options": [x for x in self.options] + "options": [x for x in self.options], + "type": self.type, } class RemoteInstall: def __init__(self, plugin): - self.themeDb = "https://github.com/suchmememanyskill/CssLoader-ThemeDb/releases/download/1.0.0/themes.json" + self.themeDb = "https://github.com/suchmememanyskill/CssLoader-ThemeDb/releases/download/1.1.0/themes.json" self.plugin = plugin self.themes = [] @@ -340,7 +355,7 @@ async def load(self, force : bool = False) -> Result: if force or (self.themes == []): response = await self.run(f"curl {self.themeDb} -L") self.themes = json.loads(response) - self.plugin.log.info(self.themes) + Log(f"Got {len(self.themes)} from the themedb") except Exception as e: return Result(False, str(e)) @@ -364,11 +379,11 @@ async def install(self, uuid : str) -> Result: tempDir = tempfile.TemporaryDirectory() - print(f"Downloading {theme['download_url']} to {tempDir.name}...") + Log(f"Downloading {theme['download_url']} to {tempDir.name}...") themeZipPath = os.path.join(tempDir.name, 'theme.zip') await self.run(f"curl \"{theme['download_url']}\" -L -o \"{themeZipPath}\"") - print(f"Unzipping {themeZipPath}") + Log(f"Unzipping {themeZipPath}") await self.run(f"unzip -o \"{themeZipPath}\" -d /home/deck/homebrew/themes") tempDir.cleanup() @@ -378,11 +393,15 @@ async def install(self, uuid : str) -> Result: return Result(True) class Plugin: + + async def dummy_function(self) -> bool: + return True + async def get_themes(self) -> list: return [x.to_dict() for x in self.themes] async def set_theme_state(self, name : str, state : bool) -> dict: - self.log.info(f"Setting state for {name} to {state}") + Log(f"Setting state for {name} to {state}") for x in self.themes: if (x.name == name): result = await x.inject() if state else await x.remove() @@ -399,6 +418,9 @@ async def get_theme_db_data(self) -> list: async def reload_theme_db_data(self) -> dict: return (await self.remote.load(True)).to_dict() + async def get_backend_version(self) -> int: + return CSS_LOADER_VER + async def set_patch_of_theme(self, themeName : str, patchName : str, value : str) -> dict: theme = None for x in self.themes: @@ -418,6 +440,9 @@ async def set_patch_of_theme(self, themeName : str, patchName : str, value : str if themePatch is None: return Result(False, f"Did not find patch '{patchName}' for theme '{themeName}'").to_dict() + if (themePatch.value == value): + return Result(True, "Already injected").to_dict() + if (value in themePatch.options): themePatch.value = value @@ -456,6 +481,7 @@ async def delete_theme(self, themeName : str) -> dict: return Result(True).to_dict() async def _inject_test_element(self, tab : str) -> Result: + attempt = 0 while True: if await self._check_test_element(self, tab): return Result(True) @@ -472,6 +498,11 @@ async def _inject_test_element(self, tab : str) -> Result: except: pass + attempt += 1 + + if (attempt >= 3): + return Result(False, f"Inject into tab '{tab}' was attempted 3 times, stopping") + await asyncio.sleep(1) @@ -496,28 +527,26 @@ async def _parse_themes(self, themesDir : str, configDir : str = None): if not path.exists(themeDataPath): continue - self.log.info(f"Analyzing theme {x}") + Log(f"Analyzing theme {x}") try: with open(themeDataPath, "r") as fp: theme = json.load(fp) - self.log.info(theme) themeData = Theme(themePath, theme, configPath) if (themeData.name not in [x.name for x in self.themes]): self.themes.append(themeData) - self.log.info(f"Adding theme {themeData.name}") + Log(f"Adding theme {themeData.name}") except Exception as e: - self.log.warn(f"Exception while parsing a theme: {e}") # Couldn't properly parse everything + Log(f"Exception while parsing a theme: {e}") # Couldn't properly parse everything async def _cache_lists(self): self.injects = [] self.tabs = [] for x in self.themes: - x.logobj = self.log injects = x.get_all_injects() self.injects.extend(injects) for y in injects: @@ -530,19 +559,19 @@ async def _check_tabs(self): await asyncio.sleep(3) for x in self.tabs: try: - self.log.info(f"Checking if tab {x} is still injected...") + # Log(f"Checking if tab {x} is still injected...") if not await self._check_test_element(self, x): - self.log.info(f"Tab {x} is not injected, reloading...") + Log(f"Tab {x} is not injected, reloading...") await self._inject_test_element(self, x) for y in self.injects: if y.enabled: (await y.inject(x)).raise_on_failure() except Exception as e: - self.log.info(f":( {str(e)}") + Log(f":( {str(e)}") pass async def _load(self): - self.log.info("Loading themes...") + Log("Loading themes...") self.themes = [] themesPath = "/home/deck/homebrew/themes" @@ -557,10 +586,8 @@ async def _load(self): async def _load_stage_2(self): for x in self.themes: - self.log.info(f"Loading theme {x.name}") - res = await x.load() - if not res.success: - self.log(res.message) + Log(f"Loading theme {x.name}") + await x.load() await self._cache_lists(self) @@ -571,16 +598,14 @@ async def _main(self): Initialized = True - self.log = getLogger("CSS_LOADER") self.themes = [] - self.log.info("Hello world!") + Log("Initializing css loader...") self.remote = RemoteInstall(self) - response = await self.remote.load() - if not response.success: - self.log.info(f":( {response.message}") + await self.remote.load() await self._load(self) await self._inject_test_element(self, "SP") await self._load_stage_2(self) + Log(f"Initialized css loader. Found {len(self.themes)} themes, which inject into {len(self.tabs)} tabs ({self.tabs}). Total {len(self.injects)} injects, {len([x for x in self.injects if x.enabled])} injected") await self._check_tabs(self) \ No newline at end of file diff --git a/package.json b/package.json index eb422a2..9408b8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "SDH-CssLoader", - "version": "1.0.0", + "version": "1.1.0", "description": "A css loader", "scripts": { "build": "shx rm -rf dist && rollup -c", diff --git a/plugin.json b/plugin.json index dd93fc4..3fc94f6 100644 --- a/plugin.json +++ b/plugin.json @@ -1,5 +1,5 @@ { - "name": "Css Loader", + "name": "CSS Loader", "author": "Such Meme, Many Skill", "flags": [], "publish": { diff --git a/src/components/ThemePatch.tsx b/src/components/ThemePatch.tsx index 78e618c..618784d 100644 --- a/src/components/ThemePatch.tsx +++ b/src/components/ThemePatch.tsx @@ -1,25 +1,93 @@ -import { DropdownItem, PanelSectionRow } from "decky-frontend-lib"; +import { + DropdownItem, + PanelSectionRow, + SliderField, + ToggleField, +} from "decky-frontend-lib"; import * as python from "../python"; -import { VFC } from "react"; +import { useState, VFC } from "react"; import { Patch } from "../theme"; -export const ThemePatch: VFC<{ data: Patch }> = ({ data }) => { - return ( - - { - return { data: i, label: x }; - })} - label={`${data.name} of ${data.theme.name}`} - selectedOption={data.index} - onChange={(index) => { - data.index = index.data; - data.value = index.label; - python.execute( - python.setPatchOfTheme(data.theme.name, data.name, data.value) - ); - }} - /> - - ); +export const ThemePatch: VFC<{ + data: Patch; + index: number; + fullArr: Patch[]; +}> = ({ data, index, fullArr }) => { + // For some reason, the other 2 don't require useStates, the slider does though. + const [sliderValue, setSlider] = useState(data.index); + + const bottomSeparatorValue = fullArr.length - 1 === index ? undefined : false; + + switch (data.type) { + case "slider": + return ( + + { + python.execute( + python.setPatchOfTheme( + data.theme.name, + data.name, + data.options[value] + ) + ); + setSlider(value); + + data.index = value; + data.value = data.options[value]; + }} + notchCount={data.options.length} + notchLabels={data.options.map((e, i) => ({ + notchIndex: i, + label: e, + value: i, + }))} + /> + + ); + case "checkbox": + return ( + + { + const newValue = bool ? "Yes" : "No"; + python.execute( + python.setPatchOfTheme(data.theme.name, data.name, newValue) + ); + data.index = data.options.findIndex((e) => e === newValue); + data.value = newValue; + }} + /> + + ); + default: + return ( + + { + return { data: i, label: x }; + })} + selectedOption={data.index} + onChange={(index) => { + data.index = index.data; + data.value = index.label; + python.execute( + python.setPatchOfTheme(data.theme.name, data.name, data.value) + ); + }} + /> + + ); + } }; diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 51d3597..0d5ae69 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -13,6 +13,9 @@ export const ThemeToggle: VFC<{ data: Theme; setThemeList: any }> = ({ <> 0 ? false : undefined + } checked={data.checked} label={data.name} description={data.description} @@ -23,7 +26,10 @@ export const ThemeToggle: VFC<{ data: Theme; setThemeList: any }> = ({ }} /> - {data.checked && data.patches.map((x) => )} + {data.checked && + data.patches.map((x, i, arr) => ( + + ))} ); }; diff --git a/src/customTypes.ts b/src/customTypes.ts index 4e92201..437bc15 100644 --- a/src/customTypes.ts +++ b/src/customTypes.ts @@ -3,10 +3,12 @@ export interface browseThemeEntry { download_url: string; id: string; name: string; + description: string; preview_image: string; version: string; last_changed: string; target: string; + manifest_version: number; } export interface localThemeEntry { author: string; diff --git a/src/index.tsx b/src/index.tsx index 6a9de6a..6e738c8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,7 +8,7 @@ import { SidebarNavigation, Router, } from "decky-frontend-lib"; -import { VFC } from "react"; +import { useEffect, useState, VFC } from "react"; import * as python from "./python"; import { RiPaintFill } from "react-icons/ri"; @@ -19,6 +19,7 @@ import { useCssLoaderState, } from "./state"; import { ThemeToggle } from "./components"; +import { ExpandedViewPage } from "./theme-manager/ExpandedView"; var firstTime: boolean = true; @@ -31,8 +32,11 @@ const Content: VFC<{ serverAPI: ServerAPI }> = () => { // setThemeList is a function that takes the raw data from the python function and then formats it with init and generate functions // This still exists, it just has been moved into the CssLoaderState class' setter function, so it now happens automatically + const [dummyFuncResult, setDummyResult] = useState(false); + const reload = function () { python.resolve(python.getThemes(), setThemeList); + dummyFuncTest(); }; if (firstTime) { @@ -40,27 +44,49 @@ const Content: VFC<{ serverAPI: ServerAPI }> = () => { reload(); } + function dummyFuncTest() { + python.resolve(python.dummyFunction(), setDummyResult); + } + + useEffect(() => { + dummyFuncTest(); + }, []); + return ( - - - { - Router.CloseSideMenus(); - Router.Navigate("/theme-manager"); - }}> - Manage Themes - - - {themeList.map((x) => ( - - ))} + + {dummyFuncResult ? ( + <> + + { + Router.CloseSideMenus(); + Router.Navigate("/theme-manager"); + }} + > + Manage Themes + + + {themeList.map((x) => ( + + ))} + + ) : ( + + + CssLoader failed to initialize, try reloading, and if that doesn't + work, try restarting your deck. + + + )} + { python.resolve(python.reset(), () => reload()); - }}> + }} + > Reload themes @@ -71,7 +97,7 @@ const Content: VFC<{ serverAPI: ServerAPI }> = () => { const ThemeManagerRouter: VFC = () => { return ( { )); + serverApi.routerHook.addRoute("/theme-manager-expanded-view", () => ( + + + + )); + return { - title:
Css Loader
, + title:
CSS Loader
, content: ( diff --git a/src/python.ts b/src/python.ts index f2ca584..49b790c 100644 --- a/src/python.ts +++ b/src/python.ts @@ -4,60 +4,79 @@ import { ServerAPI } from "decky-frontend-lib"; var server: ServerAPI | undefined = undefined; export function resolve(promise: Promise, setter: any) { - (async function () { - let data = await promise; - if (data.success) { - console.debug("Got resolved", data, "promise", promise); - setter(data.result); - } else { - console.warn("Resolve failed:", data, "promise", promise); - } - })(); + (async function () { + let data = await promise; + if (data.success) { + console.debug("Got resolved", data, "promise", promise); + setter(data.result); + } else { + console.warn("Resolve failed:", data, "promise", promise); + } + })(); } export function execute(promise: Promise) { - (async function () { - let data = await promise; - if (data.success) { - console.debug("Got executed", data, "promise", promise); - } else { - console.warn("Execute failed:", data, "promise", promise); - } - })(); + (async function () { + let data = await promise; + if (data.success) { + console.debug("Got executed", data, "promise", promise); + } else { + console.warn("Execute failed:", data, "promise", promise); + } + })(); } export function setServer(s: ServerAPI) { - server = s; + server = s; } export function getThemes(): Promise { - return server!.callPluginMethod("get_themes", {}) + return server!.callPluginMethod("get_themes", {}); } -export function setThemeState(name : string, state : boolean): Promise { - return server!.callPluginMethod("set_theme_state", {"name": name, "state": state}) +export function setThemeState(name: string, state: boolean): Promise { + return server!.callPluginMethod("set_theme_state", { + name: name, + state: state, + }); } export function reset(): Promise { - return server!.callPluginMethod("reset", {}) + return server!.callPluginMethod("reset", {}); } -export function setPatchOfTheme(themeName : string, patchName : string, value : string) : Promise { - return server!.callPluginMethod("set_patch_of_theme", {"themeName": themeName, "patchName": patchName, "value": value}) +export function setPatchOfTheme( + themeName: string, + patchName: string, + value: string +): Promise { + return server!.callPluginMethod("set_patch_of_theme", { + themeName: themeName, + patchName: patchName, + value: value, + }); } -export function downloadTheme(uuid : string) : Promise { - return server!.callPluginMethod("download_theme", {"uuid": uuid}) +export function downloadTheme(uuid: string): Promise { + return server!.callPluginMethod("download_theme", { uuid: uuid }); } -export function getThemeDbData() : Promise { - return server!.callPluginMethod("get_theme_db_data", {}) +export function getThemeDbData(): Promise { + return server!.callPluginMethod("get_theme_db_data", {}); } -export function reloadThemeDbData() : Promise { - return server!.callPluginMethod("reload_theme_db_data", {}) +export function reloadThemeDbData(): Promise { + return server!.callPluginMethod("reload_theme_db_data", {}); } -export function deleteTheme(themeName : string) : Promise { - return server!.callPluginMethod("delete_theme", {"themeName": themeName}) +export function deleteTheme(themeName: string): Promise { + return server!.callPluginMethod("delete_theme", { themeName: themeName }); +} + +export function getBackendVersion(): Promise { + return server!.callPluginMethod("get_backend_version", {}); +} + +export function dummyFunction(): Promise { + return server!.callPluginMethod("dummy_function", {}); } \ No newline at end of file diff --git a/src/state/CssLoaderState.tsx b/src/state/CssLoaderState.tsx index c7900c9..531063d 100644 --- a/src/state/CssLoaderState.tsx +++ b/src/state/CssLoaderState.tsx @@ -1,3 +1,4 @@ +import { SingleDropdownOption } from "decky-frontend-lib"; import { createContext, FC, useContext, useEffect, useState } from "react"; import { localThemeEntry, browseThemeEntry } from "../customTypes"; import { Theme } from "../theme"; @@ -5,6 +6,11 @@ import { Theme } from "../theme"; interface PublicCssLoaderState { localThemeList: Theme[]; browseThemeList: browseThemeEntry[]; + searchFieldValue: string; + selectedSort: number; + selectedTarget: SingleDropdownOption; + isInstalling: boolean; + currentExpandedTheme: browseThemeEntry | undefined; } // The localThemeEntry interface refers to the theme data as given by the python function, the Theme class refers to a theme after it has been formatted and the generate function has been added @@ -12,12 +18,25 @@ interface PublicCssLoaderState { interface PublicCssLoaderContext extends PublicCssLoaderState { setLocalThemeList(listArr: localThemeEntry[]): void; setBrowseThemeList(listArr: browseThemeEntry[]): void; + setSearchValue(value: string): void; + setSort(value: number): void; + setTarget(value: SingleDropdownOption): void; + setInstalling(bool: boolean): void; + setCurExpandedTheme(theme: browseThemeEntry | undefined): void; } // This class creates the getter and setter functions for all of the global state data. export class CssLoaderState { private localThemeList: Theme[] = []; private browseThemeList: browseThemeEntry[] = []; + private searchFieldValue: string = ""; + private selectedSort: number = 3; + private selectedTarget: SingleDropdownOption = { + data: 1, + label: "All", + }; + private isInstalling: boolean = false; + private currentExpandedTheme: browseThemeEntry | undefined = undefined; // You can listen to this eventBus' 'stateUpdate' event and use that to trigger a useState or other function that causes a re-render public eventBus = new EventTarget(); @@ -26,6 +45,11 @@ export class CssLoaderState { return { localThemeList: this.localThemeList, browseThemeList: this.browseThemeList, + searchFieldValue: this.searchFieldValue, + selectedSort: this.selectedSort, + selectedTarget: this.selectedTarget, + isInstalling: this.isInstalling, + currentExpandedTheme: this.currentExpandedTheme, }; } @@ -49,6 +73,31 @@ export class CssLoaderState { this.forceUpdate(); } + setSearchValue(value: string) { + this.searchFieldValue = value; + this.forceUpdate(); + } + + setSort(value: number) { + this.selectedSort = value; + this.forceUpdate(); + } + + setTarget(value: SingleDropdownOption) { + this.selectedTarget = value; + this.forceUpdate(); + } + + setInstalling(bool: boolean) { + this.isInstalling = bool; + this.forceUpdate(); + } + + setCurExpandedTheme(theme: browseThemeEntry | undefined) { + this.currentExpandedTheme = theme; + this.forceUpdate(); + } + private forceUpdate() { this.eventBus.dispatchEvent(new Event("stateUpdate")); } @@ -85,10 +134,29 @@ export const CssLoaderContextProvider: FC = ({ cssLoaderStateClass.setLocalThemeList(listArr); const setBrowseThemeList = (listArr: browseThemeEntry[]) => cssLoaderStateClass.setBrowseThemeList(listArr); + const setSearchValue = (value: string) => + cssLoaderStateClass.setSearchValue(value); + const setSort = (value: number) => cssLoaderStateClass.setSort(value); + const setTarget = (value: SingleDropdownOption) => + cssLoaderStateClass.setTarget(value); + const setInstalling = (bool: boolean) => + cssLoaderStateClass.setInstalling(bool); + const setCurExpandedTheme = (theme: browseThemeEntry | undefined) => + cssLoaderStateClass.setCurExpandedTheme(theme); return ( + value={{ + ...publicState, + setLocalThemeList, + setBrowseThemeList, + setSearchValue, + setSort, + setTarget, + setInstalling, + setCurExpandedTheme, + }} + > {children} ); diff --git a/src/theme-manager/ExpandedView.tsx b/src/theme-manager/ExpandedView.tsx new file mode 100644 index 0000000..a371cf5 --- /dev/null +++ b/src/theme-manager/ExpandedView.tsx @@ -0,0 +1,188 @@ +import { ButtonItem, PanelSectionRow, Router } from "decky-frontend-lib"; +import { useEffect, useRef, VFC } from "react"; + +import * as python from "../python"; + +// Interfaces for the JSON objects the lists work with +import { browseThemeEntry } from "../customTypes"; +import { useCssLoaderState } from "../state"; +import { Theme } from "../theme"; + +export const ExpandedViewPage: VFC = () => { + const { + localThemeList: installedThemes, + setLocalThemeList: setInstalledThemes, + currentExpandedTheme, + setCurExpandedTheme, + isInstalling, + setInstalling, + } = useCssLoaderState(); + + function installTheme(id: string) { + // TODO: most of this is repeating code in other functions, I can probably refactor it to shorten it + setInstalling(true); + python.resolve(python.downloadTheme(id), () => { + python.resolve(python.reset(), () => { + python.resolve(python.getThemes(), setInstalledThemes); + setInstalling(false); + }); + }); + } + + function checkIfThemeInstalled(themeObj: browseThemeEntry) { + const filteredArr: Theme[] = installedThemes.filter( + (e: Theme) => + e.data.name === themeObj.name && e.data.author === themeObj.author + ); + if (filteredArr.length > 0) { + if (filteredArr[0].data.version === themeObj.version) { + return "installed"; + } else { + return "outdated"; + } + } else { + return "uninstalled"; + } + } + // These are just switch statements I use to determine text/css for the buttons + // I put them up here just because I find it clearer to read when they aren't inline + function calcButtonColor(installStatus: string) { + let filterCSS = ""; + switch (installStatus) { + case "outdated": + filterCSS = + "invert(6%) sepia(90%) saturate(200%) hue-rotate(160deg) contrast(122%)"; + break; + default: + filterCSS = ""; + break; + } + return filterCSS; + } + function calcButtonText(installStatus: string) { + let buttonText = ""; + switch (installStatus) { + case "installed": + buttonText = "Installed"; + break; + case "outdated": + buttonText = "Update"; + break; + default: + buttonText = "Install"; + break; + } + return buttonText; + } + + const backButtonRef = useRef(null); + useEffect(() => { + // @ts-ignore + backButtonRef?.current.focus(); + }, []); + + // if theres no theme in the detailed view + if (currentExpandedTheme) { + // This returns 'installed', 'outdated', or 'uninstalled' + const installStatus = checkIfThemeInstalled(currentExpandedTheme); + return ( + // The outermost div is to push the content down into the visible area +
+
+
+ +
+
+ + {currentExpandedTheme.name} + + {currentExpandedTheme.author} + {currentExpandedTheme.target} + {currentExpandedTheme.version} +
+
+ +
+ { + installTheme(currentExpandedTheme.id); + }} + > + + {calcButtonText(installStatus)} + + +
+
+ +
+ { + setCurExpandedTheme(undefined); + Router.NavigateBackOrOpenMenu(); + }} + > + + Back + + +
+
+
+
+
+
+ + {currentExpandedTheme?.description || ( + No description provided. + )} + +
+
+
+ ); + } + return ( + <> + Error fetching selected theme, please go back and retry. + + ); +}; diff --git a/src/theme-manager/ThemeBrowserPage.tsx b/src/theme-manager/ThemeBrowserPage.tsx index 9cc48f0..1ad1af5 100644 --- a/src/theme-manager/ThemeBrowserPage.tsx +++ b/src/theme-manager/ThemeBrowserPage.tsx @@ -5,7 +5,7 @@ import { TextField, DropdownOption, DropdownItem, - SingleDropdownOption, + Router, } from "decky-frontend-lib"; import { useEffect, useMemo, useState, VFC } from "react"; @@ -22,18 +22,41 @@ export const ThemeBrowserPage: VFC = () => { setBrowseThemeList: setThemeArr, localThemeList: installedThemes, setLocalThemeList: setInstalledThemes, + searchFieldValue, + setSearchValue, + selectedSort, + setSort, + selectedTarget, + setTarget, + isInstalling, + setCurExpandedTheme, } = useCssLoaderState(); - const [searchFieldValue, setSearchValue] = useState(""); - // This is used to disable buttons during a theme install - const [isInstalling, setInstalling] = useState(false); + // THESE HAVE BEEN MOVED TO GLOBAL STATE + // These are the legacy "local state" versions of them, only uncomment if global state is broken and not working + // const [searchFieldValue, setSearchValue] = useState(""); + // const [isInstalling, setInstalling] = useState(false); + // const [selectedTarget, setTarget] = useState({ data: 1, label: "All", }); + // const [selectedSort, setSort] = useState(3); + + const [backendVersion, setBackendVer] = useState(2); + function reloadBackendVer() { + python.resolve(python.getBackendVersion(), setBackendVer); + } const searchFilter = (e: browseThemeEntry) => { + // This means only compatible themes will show up, newer ones won't + if (e.manifest_version > backendVersion) { + return false; + } // This filter just implements the search stuff if (searchFieldValue.length > 0) { + // Convert the theme and search to lowercase so that it's not case-sensitive if ( - // Convert the theme name and search to lowercase so that it's not case-sensitive - !e.name.toLowerCase().includes(searchFieldValue.toLowerCase()) + // This checks for the theme name + !e.name.toLowerCase().includes(searchFieldValue.toLowerCase()) && + // This checks for the author name + !e.author.toLowerCase().includes(searchFieldValue.toLowerCase()) ) { // return false just means it won't show in the list return false; @@ -42,32 +65,29 @@ export const ThemeBrowserPage: VFC = () => { return true; }; - const [selectedSort, setSort] = useState(1); const sortOptions = useMemo( (): DropdownOption[] => [ - { data: 1, label: "Name: A-Z" }, - { data: 2, label: "Name: Z-A" }, - { data: 3, label: "Date: Newest-Oldest" }, - { data: 4, label: "Date: Oldest-Newest" }, + { data: 1, label: "Alphabetical (A to Z)" }, + { data: 2, label: "Alphabetical (Z to A)" }, + { data: 3, label: "Last Updated (Newest)" }, + { data: 4, label: "Last Updated (Oldest)" }, ], [] ); - const [selectedTarget, setTarget] = useState({ - data: 1, - label: "Any", - }); const targetOptions = useMemo((): DropdownOption[] => { const uniqueTargets = new Set( themeArr.filter(searchFilter).map((e) => e.target) ); return [ - { data: 1, label: "Any" }, - ...[...uniqueTargets].map((e, i) => ({ data: i + 2, label: e })), + { data: 1, label: "All" }, + { data: 2, label: "Installed" }, + ...[...uniqueTargets].map((e, i) => ({ data: i + 3, label: e })), ]; }, [themeArr, searchFilter]); function reloadThemes() { + reloadBackendVer(); // Reloads the theme database python.resolve(python.reloadThemeDbData(), () => { python.resolve(python.getThemeDbData(), setThemeArr); @@ -85,16 +105,18 @@ export const ThemeBrowserPage: VFC = () => { python.resolve(python.getThemes(), setInstalledThemes); } - function installTheme(id: string) { - // TODO: most of this is repeating code in other functions, I can probably refactor it to shorten it - setInstalling(true); - python.resolve(python.downloadTheme(id), () => { - python.resolve(python.reset(), () => { - python.resolve(python.getThemes(), setInstalledThemes); - setInstalling(false); - }); - }); - } + // Installing is now handled on the ExpandedView + + // function installTheme(id: string) { + // // TODO: most of this is repeating code in other functions, I can probably refactor it to shorten it + // setInstalling(true); + // python.resolve(python.downloadTheme(id), () => { + // python.resolve(python.reset(), () => { + // python.resolve(python.getThemes(), setInstalledThemes); + // setInstalling(false); + // }); + // }); + // } function checkIfThemeInstalled(themeObj: browseThemeEntry) { const filteredArr: Theme[] = installedThemes.filter( @@ -126,24 +148,10 @@ export const ThemeBrowserPage: VFC = () => { } return filterCSS; } - function calcButtonText(installStatus: string) { - let buttonText = ""; - switch (installStatus) { - case "installed": - buttonText = "Installed"; - break; - case "outdated": - buttonText = "Update"; - break; - default: - buttonText = "Install"; - break; - } - return buttonText; - } // Runs upon opening the page useEffect(() => { + reloadBackendVer(); getThemeDb(); getInstalledThemes(); }, []); @@ -152,23 +160,23 @@ export const ThemeBrowserPage: VFC = () => { <> setSort(e.data)} /> setTarget(e)} /> setSearchValue(e.target.value)} /> @@ -176,25 +184,33 @@ export const ThemeBrowserPage: VFC = () => { {/* I wrap everything in a Focusable, because that ensures that the dpad/stick navigation works correctly */} {themeArr + // searchFilter also includes backend version check .filter(searchFilter) - .filter((e: browseThemeEntry) => - selectedTarget.label === "Any" - ? true - : e.target === selectedTarget.label - ) + .filter((e: browseThemeEntry) => { + if (selectedTarget.label === "All") { + return true; + } else if (selectedTarget.label === "Installed") { + const strValue = checkIfThemeInstalled(e); + return strValue === "installed" || strValue === "outdated"; + } else { + return e.target === selectedTarget.label; + } + }) .sort((a, b) => { // This handles the sort option the user has chosen - // 1: A-Z, 2: Z-A, 3: New-Old, 4: Old-New switch (selectedSort) { case 2: + // Z-A // localeCompare just sorts alphabetically return b.name.localeCompare(a.name); case 3: + // New-Old return ( new Date(b.last_changed).valueOf() - new Date(a.last_changed).valueOf() ); case 4: + // Old-New return ( new Date(a.last_changed).valueOf() - new Date(b.last_changed).valueOf() @@ -208,133 +224,159 @@ export const ThemeBrowserPage: VFC = () => { const installStatus = checkIfThemeInstalled(e); return ( // The outer 2 most divs are the background darkened/blurred image, and everything inside is the text/image/buttons -
+ <>
- - {e.name} - - {selectedTarget.label === "Any" && ( - - {e.target} - - )} + // Uncomment the next line and comment out the backgroundImage line if you want to try the new "greyed out bg" style + // background: "#0000", + backgroundImage: 'url("' + e.preview_image + '")', + backgroundSize: "cover", + backgroundRepeat: "no-repeat", + backgroundPosition: "center", + width: "260px", + borderRadius: "5px", + marginLeft: "10px", + marginRight: "10px", + marginBottom: "20px", + }} + >
-
+ > - {e.author} + textAlign: "center", + marginTop: "5px", + fontSize: "1.25em", + fontWeight: "bold", + // This stuff here truncates it if it's too long + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + width: "90%", + }} + > + {e.name} - + {e.target} + + )} +
- {e.version} - -
-
- -
+
+ + {e.author} + + - { - installTheme(e.id); - }}> - - {calcButtonText(installStatus)} - - -
-
+ marginLeft: "auto", + fontSize: "1em", + textShadow: "rgb(48, 48, 48) 0px 0 10px", + }} + > + {e.version} + +
+
+ +
+ { + setCurExpandedTheme(e); + Router.Navigate("/theme-manager-expanded-view"); + }} + > + + {installStatus === "outdated" + ? "Update Available" + : "View Details"} + + +
+
+
-
+ ); })} { reloadThemes(); - }}> + }} + > Reload Themes diff --git a/src/theme-manager/UninstallThemePage.tsx b/src/theme-manager/UninstallThemePage.tsx index 0d9a3a4..d5e9895 100644 --- a/src/theme-manager/UninstallThemePage.tsx +++ b/src/theme-manager/UninstallThemePage.tsx @@ -40,7 +40,8 @@ export const UninstallThemePage: VFC = () => { handleUninstall(e)} - disabled={isUninstalling}> + disabled={isUninstalling} + > diff --git a/src/theme.tsx b/src/theme.ts similarity index 94% rename from src/theme.tsx rename to src/theme.ts index a214821..05ec15c 100644 --- a/src/theme.tsx +++ b/src/theme.ts @@ -33,6 +33,7 @@ export class Patch { value: string = ""; options: string[] = []; index: number = 0; + type: string = "dropdown"; constructor(theme: Theme) { this.theme = theme; @@ -43,6 +44,7 @@ export class Patch { this.default = this.data.default; this.value = this.data.value; this.options = this.data.options; + this.type = this.data.type; this.index = this.options.indexOf(this.value); }