Add custom themes

This commit is contained in:
Geomitron
2024-07-11 23:10:34 -05:00
parent 2e08dec589
commit cb6e51be24
12 changed files with 347 additions and 37 deletions

View File

@@ -35,6 +35,7 @@
"@angular/router": "^18.0.6",
"bootstrap-icons": "^1.11.3",
"bottleneck": "^2.19.5",
"culori": "^4.0.1",
"dayjs": "^1.11.11",
"electron-unhandled": "^5.0.0",
"electron-updater": "^6.2.1",
@@ -59,6 +60,7 @@
"@angular/cli": "^18.0.7",
"@angular/compiler-cli": "^18.0.6",
"@angular/language-service": "^18.0.6",
"@types/culori": "^2.1.1",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^18.16.0",

17
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
bottleneck:
specifier: ^2.19.5
version: 2.19.5
culori:
specifier: ^4.0.1
version: 4.0.1
dayjs:
specifier: ^1.11.11
version: 1.11.11
@@ -105,6 +108,9 @@ importers:
'@angular/language-service':
specifier: ^18.0.6
version: 18.0.6
'@types/culori':
specifier: ^2.1.1
version: 2.1.1
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@@ -1173,6 +1179,9 @@ packages:
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/culori@2.1.1':
resolution: {integrity: sha512-NzLYD0vNHLxTdPp8+RlvGbR2NfOZkwxcYGFwxNtm+WH2NuUNV8785zv1h0sulFQ5aFQ9n/jNDUuJeo3Bh7+oFA==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -1824,6 +1833,10 @@ packages:
resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
culori@4.0.1:
resolution: {integrity: sha512-LSnjA6HuIUOlkfKVbzi2OlToZE8OjFi667JWN9qNymXVXzGDmvuP60SSgC+e92sd7B7158f7Fy3Mb6rXS5EDPw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
daisyui@4.12.10:
resolution: {integrity: sha512-jp1RAuzbHhGdXmn957Z2XsTZStXGHzFfF0FgIOZj3Wv9sH7OZgLfXTRZNfKVYxltGUOBsG1kbWAdF5SrqjebvA==}
engines: {node: '>=16.9.0'}
@@ -5161,6 +5174,8 @@ snapshots:
'@types/node': 18.16.0
'@types/responselike': 1.0.3
'@types/culori@2.1.1': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 0.7.34
@@ -5946,6 +5961,8 @@ snapshots:
culori@3.3.0: {}
culori@4.0.1: {}
daisyui@4.12.10(postcss@8.4.39):
dependencies:
css-selector-tokenizer: 0.8.0

View File

@@ -17,38 +17,11 @@
</div>
</label>
<label class="form-control w-full max-w-xs">
<div class="form-control w-full max-w-xs">
<div class="label">
<span class="label-text">Appearance</span>
</div>
<div class="flex gap-3 items-center">
<div class="dropdown dropdown-hover">
<label tabindex="0" class="btn btn-neutral">{{ capitalize(settingsService.theme) }}</label>
<ul tabindex="0" class="dropdown-content z-[2] menu p-2 shadow bg-neutral text-neutral-content rounded-box w-36">
<li>
<h2 class="menu-title text-neutral-content text-opacity-50">Dark</h2>
<ul>
<li><a (click)="setTheme('business')">Business</a></li>
<li><a (click)="setTheme('dark')">Dark</a></li>
<li><a (click)="setTheme('dim')">Dim</a></li>
<li><a (click)="setTheme('night')">Night</a></li>
<li><a (click)="setTheme('sunset')">Sunset</a></li>
<li><a (click)="setTheme('synthwave')">Synthwave</a></li>
</ul>
</li>
<li>
<h2 class="menu-title text-neutral-content text-opacity-50">Light</h2>
<ul>
<li><a (click)="setTheme('aqua')">Aqua</a></li>
<li><a (click)="setTheme('emerald')">Emerald</a></li>
<li><a (click)="setTheme('lemonade')">Lemonade</a></li>
<li><a (click)="setTheme('nord')">Nord</a></li>
<li><a (click)="setTheme('valentine')">Valentine</a></li>
<li><a (click)="setTheme('winter')">Winter</a></li>
</ul>
</li>
</ul>
</div>
<div class="join join-vertical">
<div class="tooltip tooltip-right" data-tip="Zoom In (ctrl +)">
<button class="join-item btn btn-square btn-neutral btn-xs" (click)="settingsService.zoomIn()">
@@ -61,8 +34,40 @@
</button>
</div>
</div>
@if (!settingsService.customTheme) {
<div class="dropdown dropdown-hover">
<label tabindex="0" class="btn btn-neutral">{{ capitalize(settingsService.theme) }}</label>
<ul tabindex="0" class="dropdown-content z-[2] menu p-2 shadow bg-neutral text-neutral-content rounded-box w-36">
<li>
<h2 class="menu-title text-neutral-content text-opacity-50">Dark</h2>
<ul>
<li><a (click)="setTheme('business')">Business</a></li>
<li><a (click)="setTheme('dark')">Dark</a></li>
<li><a (click)="setTheme('dim')">Dim</a></li>
<li><a (click)="setTheme('night')">Night</a></li>
<li><a (click)="setTheme('sunset')">Sunset</a></li>
<li><a (click)="setTheme('synthwave')">Synthwave</a></li>
</ul>
</li>
<li>
<h2 class="menu-title text-neutral-content text-opacity-50">Light</h2>
<ul>
<li><a (click)="setTheme('aqua')">Aqua</a></li>
<li><a (click)="setTheme('emerald')">Emerald</a></li>
<li><a (click)="setTheme('lemonade')">Lemonade</a></li>
<li><a (click)="setTheme('nord')">Nord</a></li>
<li><a (click)="setTheme('valentine')">Valentine</a></li>
<li><a (click)="setTheme('winter')">Winter</a></li>
</ul>
</li>
</ul>
</div>
<button (click)="getCustomTheme()" class="btn btn-primary">Use custom theme</button>
} @else {
<button (click)="clearCustomTheme()" class="btn btn-neutral">Clear custom theme</button>
}
</div>
</label>
</div>
<div class="form-control">
<div class="label">

View File

@@ -76,6 +76,31 @@ export class SettingsComponent implements OnInit {
}
}
async getCustomTheme() {
const result = await window.electron.invoke.showOpenDialog({
title: 'Choose custom theme',
defaultPath: this.settingsService.libraryDirectory || '',
properties: ['openFile'],
filters: [
{ name: "Themes", extensions: ["json"] },
],
})
if (result.canceled === false) {
const newThemeColors = await window.electron.invoke.getThemeColors(result.filePaths[0])
if (newThemeColors) {
this.settingsService.customTheme = newThemeColors
} else {
alert(`ERROR: ${result.filePaths[0]} was not a valid JSON file.`)
}
}
}
clearCustomTheme() {
this.settingsService.customTheme = null
}
openLibraryDirectory() {
if (this.settingsService.libraryDirectory) {
window.electron.emit.showFolder(this.settingsService.libraryDirectory)

View File

@@ -4,7 +4,9 @@ import { Inject, Injectable } from '@angular/core'
import _ from 'lodash'
import { Difficulty, Instrument } from 'scan-chart'
import { ThemeColors } from '../../../../src-shared/interfaces/theme.interface.js'
import { Settings, themes } from '../../../../src-shared/Settings.js'
import { colorNames, convertColorFormat } from '../../../../src-shared/UtilFunctions.js'
@Injectable({
providedIn: 'root',
@@ -19,18 +21,20 @@ export class SettingsService {
async loadSettings() {
this.settings = await window.electron.invoke.getSettings()
if (!themes.includes(this.settings.theme)) {
if (this.settings.customTheme) {
setThemeColors(this.settings.customTheme)
} else if (!themes.includes(this.settings.theme)) {
this.changeTheme('dark')
} else {
this.changeTheme(this.settings.theme)
}
}
saveSettings() {
private saveSettings() {
window.electron.emit.setSettings(this.settings)
}
changeTheme(theme: typeof themes[number]) {
private changeTheme(theme: typeof themes[number]) {
this.document.documentElement.setAttribute('data-theme', theme)
}
@@ -50,7 +54,6 @@ export class SettingsService {
this.saveSettings()
}
// Individual getters/setters
get libraryDirectory() {
return this.settings.libraryPath
}
@@ -70,9 +73,27 @@ export class SettingsService {
get theme() {
return this.settings.theme
}
set theme(newValue: typeof themes[number]) {
this.settings.theme = newValue
this.changeTheme(newValue)
set theme(value: typeof themes[number]) {
this.settings.theme = value
if (!this.settings.customTheme) {
this.changeTheme(value)
}
this.saveSettings()
}
get customTheme() {
return this.settings.customTheme
}
set customTheme(value: ThemeColors | null) {
if (value) {
const failed = setThemeColors(value)
if (failed) { return }
} else {
for (const themeColor in colorNames) {
document.documentElement.style.removeProperty(colorNames[themeColor])
}
this.changeTheme(this.settings.theme)
}
this.settings.customTheme = value
this.saveSettings()
}
@@ -108,3 +129,16 @@ export class SettingsService {
}
}
}
function setThemeColors(themeColors: ThemeColors) {
try {
const result = convertColorFormat(themeColors)
for (const cssKey in result) {
document.documentElement.style.setProperty(cssKey, result[cssKey])
}
return false
} catch (err) {
alert(`ERROR: the provided color theme is improperly formatted.`)
return true
}
}

View File

@@ -2,7 +2,7 @@ import { IpcInvokeHandlers, IpcToMainEmitHandlers } from '../src-shared/interfac
import { download } from './ipc/DownloadHandler.ipc.js'
import { getSettings, setSettings } from './ipc/SettingsHandler.ipc.js'
import { downloadUpdate, getCurrentVersion, getUpdateAvailable, quitAndInstall, retryUpdate } from './ipc/UpdateHandler.ipc.js'
import { getPlatform, isMaximized, maximize, minimize, openUrl, quit, restore, showFile, showFolder, showOpenDialog, toggleDevTools } from './ipc/UtilHandlers.ipc.js'
import { getPlatform, getThemeColors, isMaximized, maximize, minimize, openUrl, quit, restore, showFile, showFolder, showOpenDialog, toggleDevTools } from './ipc/UtilHandlers.ipc.js'
export function getIpcInvokeHandlers(): IpcInvokeHandlers {
return {
@@ -12,6 +12,7 @@ export function getIpcInvokeHandlers(): IpcInvokeHandlers {
getUpdateAvailable,
isMaximized,
showOpenDialog,
getThemeColors,
}
}

View File

@@ -1,5 +1,7 @@
import { app, dialog, OpenDialogOptions, shell } from 'electron'
import fsExtra from 'fs-extra'
import { ThemeColors } from '../../src-shared/interfaces/theme.interface.js'
import { mainWindow } from '../main.js'
/**
@@ -48,3 +50,11 @@ export function showFile(filePath: string) {
export async function getPlatform() {
return process.platform
}
export async function getThemeColors(path: string) {
try {
return await fsExtra.readJson(path) as ThemeColors
} catch (err) {
return null
}
}

View File

@@ -24,6 +24,7 @@ const electronApi: ContextBridgeApi = {
getUpdateAvailable: getInvoker('getUpdateAvailable'),
isMaximized: getInvoker('isMaximized'),
showOpenDialog: getInvoker('showOpenDialog'),
getThemeColors: getInvoker('getThemeColors'),
},
emit: {
download: getEmitter('download'),

View File

@@ -1,5 +1,7 @@
import { Difficulty, Instrument } from 'scan-chart'
import { ThemeColors } from './interfaces/theme.interface'
export const themes = [
'business',
'dark',
@@ -23,6 +25,7 @@ export const themes = [
export interface Settings {
downloadVideos: boolean // If background videos should be downloaded
theme: typeof themes[number] // The name of the currently enabled UI theme
customTheme: ThemeColors | null // The colors of a custom theme
libraryPath: string | undefined // The path to the user's library
isSng: boolean // If the chart should be downloaded as a .sng file or as a chart folder
isCompactTable: boolean // If the search result table should have reduced padding
@@ -37,6 +40,7 @@ export interface Settings {
export const defaultSettings: Settings = {
downloadVideos: true,
theme: 'dark',
customTheme: null,
libraryPath: undefined,
isSng: false,
isCompactTable: false,

View File

@@ -1,7 +1,9 @@
import { interpolate as culoriInterpolate, oklch, wcagContrast } from 'culori'
import _ from 'lodash'
import { Difficulty, Instrument } from 'scan-chart'
import { ChartData } from './interfaces/search.interface'
import { ThemeColors } from './interfaces/theme.interface'
// WARNING: do not import anything related to Electron; the code will not compile correctly.
@@ -166,3 +168,179 @@ export function hasIssues(chart: Pick<ChartData, 'metadataIssues' | 'folderIssue
return false
}
/* eslint-disable @typescript-eslint/naming-convention */
export const colorNames = {
"primary": "--p",
"primary-content": "--pc",
"secondary": "--s",
"secondary-content": "--sc",
"accent": "--a",
"accent-content": "--ac",
"neutral": "--n",
"neutral-content": "--nc",
"base-100": "--b1",
"base-200": "--b2",
"base-300": "--b3",
"base-content": "--bc",
"info": "--in",
"info-content": "--inc",
"success": "--su",
"success-content": "--suc",
"warning": "--wa",
"warning-content": "--wac",
"error": "--er",
"error-content": "--erc",
} as { [colorName: string]: string }
const defaultVariables = {
"--rounded-box": "1rem",
"--rounded-btn": "0.5rem",
"--rounded-badge": "1.9rem",
"--animation-btn": "0.25s",
"--animation-input": ".2s",
"--btn-focus-scale": "0.95",
"--border-btn": "1px",
"--tab-border": "1px",
"--tab-radius": "0.5rem",
}
/* eslint-enable @typescript-eslint/naming-convention */
export function convertColorFormat(input: ThemeColors) {
if (typeof input !== "object" || input === null) {
return input
}
const resultObj: { [cssKey: string]: string } = {}
for (const [rule, value] of Object.entries(input)) {
if (Object.hasOwn(colorNames, rule)) {
const colorObj = oklch(value)
resultObj[colorNames[rule]] = colorObjToString(colorObj!)
} else {
resultObj[rule] = value
}
// auto generate base colors
if (!Object.hasOwn(input, "base-100")) {
resultObj["--b1"] = "100% 0 0"
}
if (!Object.hasOwn(input, "base-200")) {
resultObj["--b2"] = generateDarkenColorFrom(input["base-100"], 0.07)
}
if (!Object.hasOwn(input, "base-300")) {
if (Object.hasOwn(input, "base-200")) {
resultObj["--b3"] = generateDarkenColorFrom(input["base-200"], 0.07)
} else {
resultObj["--b3"] = generateDarkenColorFrom(input["base-100"], 0.14)
}
}
// auto generate state colors
if (!Object.hasOwn(input, "info")) {
resultObj["--in"] = "72.06% 0.191 231.6"
}
if (!Object.hasOwn(input, "success")) {
resultObj["--su"] = "64.8% 0.150 160"
}
if (!Object.hasOwn(input, "warning")) {
resultObj["--wa"] = "84.71% 0.199 83.87"
}
if (!Object.hasOwn(input, "error")) {
resultObj["--er"] = "71.76% 0.221 22.18"
}
// auto generate content colors
if (!Object.hasOwn(input, "base-content")) {
resultObj["--bc"] = generateForegroundColorFrom(input["base-100"], 0.8)
}
if (!Object.hasOwn(input, "primary-content")) {
resultObj["--pc"] = generateForegroundColorFrom(input.primary, 0.8)
}
if (!Object.hasOwn(input, "secondary-content")) {
resultObj["--sc"] = generateForegroundColorFrom(input.secondary, 0.8)
}
if (!Object.hasOwn(input, "accent-content")) {
resultObj["--ac"] = generateForegroundColorFrom(input.accent, 0.8)
}
if (!Object.hasOwn(input, "neutral-content")) {
resultObj["--nc"] = generateForegroundColorFrom(input.neutral, 0.8)
}
if (!Object.hasOwn(input, "info-content")) {
if (Object.hasOwn(input, "info")) {
resultObj["--inc"] = generateForegroundColorFrom(input.info, 0.8)
} else {
resultObj["--inc"] = "0% 0 0"
}
}
if (!Object.hasOwn(input, "success-content")) {
if (Object.hasOwn(input, "success")) {
resultObj["--suc"] = generateForegroundColorFrom(input.success, 0.8)
} else {
resultObj["--suc"] = "0% 0 0"
}
}
if (!Object.hasOwn(input, "warning-content")) {
if (Object.hasOwn(input, "warning")) {
resultObj["--wac"] = generateForegroundColorFrom(input.warning, 0.8)
} else {
resultObj["--wac"] = "0% 0 0"
}
}
if (!Object.hasOwn(input, "error-content")) {
if (Object.hasOwn(input, "error")) {
resultObj["--erc"] = generateForegroundColorFrom(input.error, 0.8)
} else {
resultObj["--erc"] = "0% 0 0"
}
}
// add css variables if not exist
for (const item of Object.entries(defaultVariables)) {
const [variable, value] = item
if (!Object.hasOwn(input, variable)) {
resultObj[variable] = value
}
}
// add other custom styles
if (!Object.hasOwn(colorNames, rule)) {
resultObj[rule] = value
}
}
return resultObj
}
function generateForegroundColorFrom(input: string, percentage = 0.8) {
const result = culoriInterpolate([input, isDark(input) ? "white" : "black"], "oklch")(percentage)
return colorObjToString(result)
}
function generateDarkenColorFrom(input: string, percentage = 0.07) {
const result = culoriInterpolate([input, "black"], "oklch")(percentage)
return colorObjToString(result)
}
function colorObjToString(input: { l: number; c: number; h?: number }) {
const { l, c, h } = input
return `${Number.parseFloat((cutNumber(l) * 100).toFixed(6))}% ${cutNumber(c)} ${cutNumber(h ?? 0)}`
}
function cutNumber(number: number) {
if (number) {
return +number.toFixed(6)
}
return 0
}
function isDark(color: string) {
return wcagContrast(color, "black") < wcagContrast(color, "white")
}

View File

@@ -3,6 +3,7 @@ import { UpdateInfo } from 'electron-updater'
import { Settings } from '../Settings.js'
import { Download, DownloadProgress } from './download.interface.js'
import { ThemeColors } from './theme.interface.js'
import { UpdateProgress } from './update.interface.js'
export interface ContextBridgeApi {
@@ -45,6 +46,10 @@ export interface IpcInvokeEvents {
input: OpenDialogOptions
output: OpenDialogReturnValue
}
getThemeColors: {
input: string
output: ThemeColors | null
}
}
export type IpcInvokeHandlers = {

View File

@@ -0,0 +1,28 @@
/* eslint-disable @typescript-eslint/naming-convention */
export interface ThemeColors {
'primary': string
'primary-focus': string
'primary-content': string
'secondary': string
'secondary-focus': string
'secondary-content': string
'accent': string
'accent-focus': string
'accent-content': string
'neutral': string
'neutral-focus': string
'neutral-content': string
'base-100': string
'base-200': string
'base-300': string
'base-content': string
'info': string
'info-content': string
'success': string
'success-content': string
'warning': string
'warning-content': string
'error': string
'error-content': string
}