From 5075c7a628139a5326ca419624a0c41ed548036b Mon Sep 17 00:00:00 2001 From: tillvit Date: Thu, 19 Sep 2024 19:34:22 -0400 Subject: [PATCH] Theme picker window --- app/index.css | 67 ++++- app/src/data/KeybindData.ts | 4 +- app/src/data/ThemeData.ts | 88 +++--- .../{ThemeWindow.ts => ThemeEditorWindow.ts} | 50 +++- app/src/gui/window/ThemeSelectionWindow.ts | 262 ++++++++++++++++++ app/src/util/Theme.ts | 83 +++++- public/assets/svg/COPY.svg | 1 + 7 files changed, 491 insertions(+), 64 deletions(-) rename app/src/gui/window/{ThemeWindow.ts => ThemeEditorWindow.ts} (76%) create mode 100644 app/src/gui/window/ThemeSelectionWindow.ts create mode 100644 public/assets/svg/COPY.svg diff --git a/app/index.css b/app/index.css index fe11d4f2..4a9eb01e 100644 --- a/app/index.css +++ b/app/index.css @@ -486,7 +486,8 @@ button:disabled { overflow: auto; padding: 5px; } -.file-options { +.file-options, +.theme-tray { display: flex; flex-direction: row; column-gap: 5px; @@ -1944,6 +1945,10 @@ input[type="range"] { gap: 10px; } +input[type="text"].pref-search-bar { + flex: 0; +} + .pref-scrollers { display: flex; gap: 20px; @@ -2761,6 +2766,7 @@ input[type="color"] { height: 250px; position: relative; border-radius: 5px; + color: white; } .noteskin-cell img { @@ -2874,7 +2880,8 @@ input[type="color"] { width: 24px; height: 24px; border-radius: 3px; - border: 1px solid var(--input-border); + border: 1px solid rgb(111, 111, 111); + overflow: hidden; } .color-picker:hover:not(:active) { @@ -2909,7 +2916,6 @@ input[type="color"] { .animated .color-picker-popup { animation: 0.1s popup-enter cubic-bezier(0.47, 0.02, 0, 0.95) forwards; - /* transition: 0.2s cubic-bezier(0.47, 0.02, 0, 0.95); */ } .animated .color-picker-popup.exiting { @@ -3037,3 +3043,58 @@ input[type="color"] { .color-picker-popup .color-picker-transparent { flex: 1; } + +.theme-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + width: 100%; + gap: 5px; + padding: 10px; + background: var(--secondary-bg); + border: 1px solid var(--secondary-border); + margin-top: 10px; + overflow: auto; + flex: 1; + border-radius: 5px 5px 0 0; +} + +.theme-cell { + padding: 15px; + display: flex; + align-items: center; + flex-direction: column; + gap: 5px; + border-radius: 5px; + height: fit-content; + min-width: 0; +} + +.theme-title { + text-align: center !important; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; +} + +.theme-cell:hover:not(.selected) { + background: var(--secondary-bg-hover); +} + +.theme-cell.selected { + background: var(--secondary-bg-active); +} + +.animated .theme-cell { + transition: 0.2s cubic-bezier(0.47, 0.02, 0, 0.95); +} + +.theme-preview-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + width: 66px; + height: 66px; + border: 1px solid rgb(0, 0, 0, 0.5); + border-radius: 5px; + overflow: hidden; +} diff --git a/app/src/data/KeybindData.ts b/app/src/data/KeybindData.ts index e4006ac8..2d83cb1c 100644 --- a/app/src/data/KeybindData.ts +++ b/app/src/data/KeybindData.ts @@ -13,7 +13,7 @@ import { NoteskinWindow } from "../gui/window/NoteskinWindow" import { OffsetWindow } from "../gui/window/OffsetWindow" import { SMPropertiesWindow } from "../gui/window/SMPropertiesWindow" import { SyncWindow } from "../gui/window/SyncWindow" -import { ThemeWindow } from "../gui/window/ThemeWindow" +import { ThemeSelectionWindow } from "../gui/window/ThemeSelectionWindow" import { TimingDataWindow } from "../gui/window/TimingDataWindow" import { UserOptionsWindow } from "../gui/window/UserOptionsWindow" import { ActionHistory } from "../util/ActionHistory" @@ -721,7 +721,7 @@ export const KEYBIND_DATA: { [key: string]: Keybind } = { combos: [], disabled: () => !Flags.openWindows || !Flags.openWindows, callback: app => { - app.windowManager.openWindow(new ThemeWindow(app)) + app.windowManager.openWindow(new ThemeSelectionWindow(app)) }, }, convertHoldsRolls: { diff --git a/app/src/data/ThemeData.ts b/app/src/data/ThemeData.ts index 7afee217..9fcdf1ad 100644 --- a/app/src/data/ThemeData.ts +++ b/app/src/data/ThemeData.ts @@ -1,5 +1,5 @@ import { Color } from "pixi.js" -import { ThemeWindow } from "../gui/window/ThemeWindow" +import { ThemeEditorWindow } from "../gui/window/ThemeEditorWindow" export const THEME_VAR_WHITELIST = [ "accent-color", @@ -48,88 +48,100 @@ export type ThemeGroup = { }[] } +export const THEME_GRID_PROPS: ThemeProperty[] = [ + "primary-bg", + "secondary-bg", + "text-color", + "accent-color", + "widget-bg", + "editor-bg", + "editable-overlay-active", + "input-bg", + "window-bg", +] + export const THEME_GROUPS: ThemeGroup[] = [ { - name: "main", + name: "primary-bg", ids: [ { - id: "accent-color", - label: "accent-color", + id: "primary-bg", + label: "base", }, { - id: "widget-bg", - label: "widget-bg", + id: "primary-bg-active", + label: "active", }, { - id: "tooltip-bg", - label: "tooltip-bg", + id: "primary-bg-hover", + label: "hover", }, { - id: "editor-bg", - label: "editor-bg", + id: "primary-border", + label: "border", }, ], }, { - name: "text-color", + name: "secondary-bg", ids: [ { - id: "text-color", - label: "primary", + id: "secondary-bg", + label: "base", }, { - id: "text-color-secondary", - label: "secondary", + id: "secondary-bg-active", + label: "active", }, { - id: "text-color-detail", - label: "detail", + id: "secondary-bg-hover", + label: "hover", }, { - id: "text-color-disabled", - label: "disabled", + id: "secondary-border", + label: "border", }, ], }, { - name: "primary-bg", + name: "text-color", ids: [ { - id: "primary-bg", - label: "base", + id: "text-color", + label: "primary", }, { - id: "primary-bg-active", - label: "active", + id: "text-color-secondary", + label: "secondary", }, { - id: "primary-bg-hover", - label: "hover", + id: "text-color-detail", + label: "detail", }, { - id: "primary-border", - label: "border", + id: "text-color-disabled", + label: "disabled", }, ], }, { - name: "secondary-bg", + name: "other", ids: [ { - id: "secondary-bg", - label: "base", + id: "accent-color", + label: "accent-color", }, { - id: "secondary-bg-active", - label: "active", + id: "widget-bg", + label: "widget-bg", }, { - id: "secondary-bg-hover", - label: "hover", + id: "tooltip-bg", + label: "tooltip-bg", }, { - id: "secondary-border", - label: "border", + id: "editor-bg", + label: "editor-bg", }, ], }, @@ -230,7 +242,7 @@ export const THEME_PROPERTY_DESCRIPTIONS: { } export type ThemeColorLinks = { - [key in ThemeProperty]?: (this: ThemeWindow, c: Color) => Color + [key in ThemeProperty]?: (this: ThemeEditorWindow, c: Color) => Color } export const THEME_GENERATOR_LINKS: { diff --git a/app/src/gui/window/ThemeWindow.ts b/app/src/gui/window/ThemeEditorWindow.ts similarity index 76% rename from app/src/gui/window/ThemeWindow.ts rename to app/src/gui/window/ThemeEditorWindow.ts index 497da0f4..feea09bc 100644 --- a/app/src/gui/window/ThemeWindow.ts +++ b/app/src/gui/window/ThemeEditorWindow.ts @@ -11,24 +11,27 @@ import { } from "../../data/ThemeData" import { add, lighten } from "../../util/Color" import { EventHandler } from "../../util/EventHandler" +import { Options } from "../../util/Options" import { Themes } from "../../util/Theme" import { ColorPicker } from "../element/ColorPicker" import { Icons } from "../Icons" +import { ThemeSelectionWindow } from "./ThemeSelectionWindow" import { Window } from "./Window" -export class ThemeWindow extends Window { +export class ThemeEditorWindow extends Window { app: App private pickers: Record = {} private handlers: ((...args: any[]) => void)[] = [] - private static linkBlacklist = new Set() + private linkBlacklist = new Set() constructor(app: App) { super({ - title: "scufed teheme enditor", + title: "Theme Color Editor", width: 500, height: 400, - win_id: "theme_window", + win_id: "theme-editor", + disableClose: true, }) this.app = app this.initView() @@ -47,7 +50,36 @@ export class ThemeWindow extends Window { colorGrid.appendChild(this.createGroup(g)) }) - padding.appendChild(colorGrid) + const menu_options = document.createElement("div") + menu_options.classList.add("menu-options") + + const menu_options_left = document.createElement("div") + menu_options_left.classList.add("menu-left") + const menu_options_right = document.createElement("div") + menu_options_right.classList.add("menu-right") + menu_options.appendChild(menu_options_left) + menu_options.appendChild(menu_options_right) + + const cancel = document.createElement("button") + cancel.innerText = "Cancel" + cancel.onclick = () => { + this.closeWindow() + Themes.loadTheme(Options.general.theme) + this.app.windowManager.openWindow(new ThemeSelectionWindow(this.app)) + } + menu_options_left.appendChild(cancel) + + const create_btn = document.createElement("button") + create_btn.innerText = "Save" + create_btn.classList.add("confirm") + create_btn.onclick = () => { + Themes.setUserTheme(Options.general.theme, Themes.getCurrentTheme()) + Themes.loadTheme(Options.general.theme) + this.app.windowManager.openWindow(new ThemeSelectionWindow(this.app)) + } + menu_options_right.appendChild(create_btn) + + padding.replaceChildren(colorGrid, menu_options) this.viewElement.appendChild(padding) } @@ -94,7 +126,7 @@ export class ThemeWindow extends Window { if (links) { container.onmouseover = () => { Object.keys(links).forEach(key => { - if (ThemeWindow.linkBlacklist.has(key as ThemeProperty)) return + if (this.linkBlacklist.has(key as ThemeProperty)) return this.pickers[key].classList.add("linked") }) } @@ -132,9 +164,9 @@ export class ThemeWindow extends Window { const update = () => { if (currentValue) { - ThemeWindow.linkBlacklist.delete(opt.id) + this.linkBlacklist.delete(opt.id) } else { - ThemeWindow.linkBlacklist.add(opt.id) + this.linkBlacklist.add(opt.id) } on.style.display = currentValue ? "" : "none" off.style.display = currentValue ? "none" : "" @@ -214,7 +246,7 @@ export class ThemeWindow extends Window { const links = THEME_GENERATOR_LINKS[currentId] if (!links) continue for (const [id, transform] of Object.entries(links)) { - if (ThemeWindow.linkBlacklist.has(id as ThemeProperty)) continue + if (this.linkBlacklist.has(id as ThemeProperty)) continue if (visited.has(id)) continue theme[id as ThemeProperty] = transform.bind(this)(theme[currentId]) queue.push(id as ThemeProperty) diff --git a/app/src/gui/window/ThemeSelectionWindow.ts b/app/src/gui/window/ThemeSelectionWindow.ts new file mode 100644 index 00000000..cb67576f --- /dev/null +++ b/app/src/gui/window/ThemeSelectionWindow.ts @@ -0,0 +1,262 @@ +import { App } from "../../App" +import { DEFAULT_THEMES, THEME_GRID_PROPS } from "../../data/ThemeData" +import { Options } from "../../util/Options" +import { Themes } from "../../util/Theme" +import { Icons } from "../Icons" +import { ConfirmationWindow } from "./ConfirmationWindow" +import { ThemeEditorWindow } from "./ThemeEditorWindow" +import { Window } from "./Window" + +export class ThemeSelectionWindow extends Window { + app: App + + private grid!: HTMLDivElement + private actions: Record = {} + + constructor(app: App) { + super({ + title: "Themes", + width: 600, + height: 400, + disableClose: false, + win_id: "theme-selection", + blocking: false, + }) + this.app = app + + this.initView() + this.loadGrid() + } + + initView(): void { + // Create the window + this.viewElement.replaceChildren() + + //Padding container + const padding = document.createElement("div") + padding.classList.add("padding") + + const searchBar = document.createElement("input") + searchBar.classList.add("pref-search-bar") + searchBar.type = "text" + searchBar.placeholder = "Search for a theme..." + + searchBar.oninput = () => { + this.filterGrid(searchBar.value) + } + + const grid = document.createElement("div") + grid.classList.add("theme-grid") + this.grid = grid + + const optionTray = this.createOptionTray() + + padding.replaceChildren(searchBar, grid, optionTray) + + this.viewElement.appendChild(padding) + } + + createOptionTray() { + const optionTray = document.createElement("div") + optionTray.classList.add("theme-tray") + + const add = document.createElement("button") + add.appendChild(Icons.getIcon("PLUS", 16)) + add.appendChild(document.createTextNode("New")) + add.onclick = () => { + const themeName = this.getNonConflictingName("new-theme") + Themes.createUserTheme(themeName) + Themes.loadTheme(themeName) + this.loadGrid() + } + this.actions.add = add + optionTray.appendChild(add) + + const copy = document.createElement("button") + copy.appendChild(Icons.getIcon("COPY", 16)) + copy.appendChild(document.createTextNode("Duplicate")) + copy.onclick = () => { + const themeName = this.getNonConflictingName(Options.general.theme) + Themes.createUserTheme(themeName, Themes.getCurrentTheme()) + Themes.loadTheme(themeName) + this.loadGrid() + } + copy.disabled = true + this.actions.copy = copy + optionTray.appendChild(copy) + + const edit = document.createElement("button") + edit.classList.add("confirm") + edit.appendChild(Icons.getIcon("EDIT", 16)) + edit.appendChild(document.createTextNode("Edit")) + edit.onclick = () => { + this.closeWindow() + this.app.windowManager.openWindow(new ThemeEditorWindow(this.app)) + } + edit.disabled = true + this.actions.edit = edit + optionTray.appendChild(edit) + + const del = document.createElement("button") + del.classList.add("delete") + del.appendChild(Icons.getIcon("TRASH", 16)) + del.appendChild(document.createTextNode("Delete")) + del.onclick = () => { + this.app.windowManager.openWindow( + new ConfirmationWindow( + this.app, + "Delete theme", + "Are you sure you want to delete this theme?", + [ + { + type: "default", + label: "Cancel", + }, + { + type: "delete", + label: "Delete", + callback: () => { + Themes.deleteUserTheme(Options.general.theme) + this.loadGrid() + }, + }, + ] + ) + ) + } + del.disabled = true + this.actions.del = del + optionTray.appendChild(del) + + // import/export + + return optionTray + } + + loadGrid() { + this.grid.replaceChildren() + + const themes = Themes.getThemes() + if (!themes) return + + for (const [id, theme] of Object.entries(themes)) { + let currentId = id + const isDefault = DEFAULT_THEMES[id] !== undefined + + const container = document.createElement("div") + const title = document.createElement("div") + title.classList.add("inlineEdit") + if (!isDefault) { + title.contentEditable = "true" + let lastValue = id + title.onfocus = () => { + lastValue = title.innerText + } + title.onkeydown = e => { + if (e.key == "Enter") { + title.blur() + e.preventDefault() + } + if (e.key == "Escape") { + title.innerText = lastValue + title.blur() + e.stopImmediatePropagation() + } + } + title.onblur = () => { + if (title.innerText == lastValue) return + const newName = this.getNonConflictingName(title.innerText) + Themes.renameUserTheme(lastValue, newName) + Themes.loadTheme(newName) + title.innerText = newName + currentId = newName + container.dataset.id = newName + } + } else { + title.style.fontWeight = "bold" + } + + const colorGrid = document.createElement("div") + colorGrid.classList.add("theme-preview-grid") + + for (const prop of THEME_GRID_PROPS) { + const cell = document.createElement("div") + cell.style.backgroundColor = theme[prop].toHex() + colorGrid.appendChild(cell) + } + + title.innerText = id + + container.classList.add("theme-cell") + title.classList.add("theme-title") + + container.replaceChildren(title, colorGrid) + this.grid.appendChild(container) + + if (id == Options.general.theme) { + container.classList.add("selected") + setTimeout(() => { + container.scrollIntoView({ + behavior: Options.general.smoothAnimations ? "smooth" : "instant", + block: "center", + }) + }) + this.actions.edit.disabled = isDefault + this.actions.del.disabled = isDefault + this.actions.copy.disabled = false + } + + container.dataset.id = id + + container.onclick = () => { + if (Options.general.theme == currentId) return + Themes.loadTheme(currentId) + this.removeAllSelections() + container.classList.add("selected") + + this.actions.edit.disabled = isDefault + this.actions.del.disabled = isDefault + this.actions.copy.disabled = false + } + } + } + + removeAllSelections() { + ;[...this.grid.querySelectorAll(".selected")].forEach(e => + e.classList.remove("selected") + ) + } + + filterGrid(query: string) { + ;[...this.grid.children].forEach(element => { + if (!(element instanceof HTMLDivElement)) return + const div: HTMLDivElement = element + const shouldDisplay = this.containsQuery(query, div.dataset.id) + div.style.display = shouldDisplay ? "" : "none" + }) + } + + containsQuery(query: string, string: string | undefined) { + if (!string) return false + return string.toLowerCase().includes(query.trim().toLowerCase()) + } + + getNonConflictingName(base: string) { + const themes = Themes.getThemes() + if (themes[base] == undefined) return base + + let i = 2 + const m = /([^]+)-(\d+)$/.exec(base) + + if (m) { + base = m[1] + i = parseInt(m[2]) + } + let name = `${base}-${i}` + while (themes[name] !== undefined) { + name = `${base}-${i}` + i++ + } + return name + } +} diff --git a/app/src/util/Theme.ts b/app/src/util/Theme.ts index 0921feca..2c3ab9b9 100644 --- a/app/src/util/Theme.ts +++ b/app/src/util/Theme.ts @@ -41,6 +41,17 @@ export class Themes { this._userThemes = loadedThemes } + private static _saveUserThemes() { + // Convert colors back into strings + const themeStrings = Object.fromEntries( + Object.entries(this._userThemes).map(([id, theme]) => [ + id, + this.convertThemeToString(theme), + ]) + ) + localStorage.setItem("themes", JSON.stringify(themeStrings)) + } + private static validateTheme(theme: ThemeString): Theme { const newTheme = DEFAULT_THEMES["default"] if (typeof theme !== "object") return newTheme @@ -57,7 +68,15 @@ export class Themes { } static getThemes() { - return { ...this._userThemes, ...this._themes } + return { ...this._themes, ...this._userThemes } + } + + static getBuiltinThemes() { + return this._themes + } + + static getUserThemes() { + return this._userThemes } static loadTheme(id: string) { @@ -82,7 +101,7 @@ export class Themes { `--${key}: ${(theme[key] ?? DEFAULT_THEMES["default"][key]).toHexa()};` ).join("")}}` this.style.innerHTML = styleText - this.currentTheme = theme + this.currentTheme = { ...theme } EventHandler.emit("themeChanged") } @@ -94,16 +113,56 @@ export class Themes { return this.currentTheme } - static exportCurrentTheme() { - return JSON.stringify( - Object.fromEntries( - Object.entries(this.currentTheme).map(([prop, col]) => [ - prop, - `^new Color('${col.toHexa()}')^`, - ]) + private static convertThemeToString(theme: Theme) { + return Object.fromEntries( + Object.entries(theme).map(([prop, col]) => [prop, col.toHexa()]) + ) as ThemeString + } + + static exportCurrentTheme(code = false) { + if (code) { + return JSON.stringify( + Object.fromEntries( + Object.entries(this.currentTheme).map(([prop, col]) => [ + prop, + `^new Color('${col.toHexa()}')^`, + ]) + ) ) - ) - .replaceAll(`"^`, "") - .replaceAll(`^"`, "") + .replaceAll(`"^`, "") + .replaceAll(`^"`, "") + } + return JSON.stringify(this.convertThemeToString(this.currentTheme)) + } + + static createUserTheme(id: string, base?: Theme) { + if (this.getThemes()[id] !== undefined) return + if (base) { + this._userThemes[id] = { ...base } + } else { + this._userThemes[id] = { ...DEFAULT_THEMES["default"] } + } + this._saveUserThemes() + } + + static setUserTheme(id: string, base: Theme) { + if (this._userThemes[id] === undefined) return + this._userThemes[id] = { ...base } + this._saveUserThemes() + } + + static deleteUserTheme(id: string) { + if (this._userThemes[id] === undefined) return + delete this._userThemes[id] + this.loadTheme("default") + this._saveUserThemes() + } + + static renameUserTheme(id: string, newId: string) { + if (this._userThemes[id] === undefined) return + if (this.getThemes()[newId] !== undefined) return + this._userThemes[newId] = this._userThemes[id] + delete this._userThemes[id] + this._saveUserThemes() } } diff --git a/public/assets/svg/COPY.svg b/public/assets/svg/COPY.svg new file mode 100644 index 00000000..53d5c9be --- /dev/null +++ b/public/assets/svg/COPY.svg @@ -0,0 +1 @@ + \ No newline at end of file