From ee234cad7c71e806d2338b4c2e34ebe12e6265ea Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:00:12 -0400 Subject: [PATCH] initial commit for theme engine --- package.json | 7 + scripts/run-theme-library-tests.mjs | 44 + src/main/index.ts | 125 +++ src/main/profileLibrary.ts | 11 +- src/main/themeLibrary.ts | 384 ++++++++ src/preload/index.ts | 18 + src/renderer/App.tsx | 20 +- src/renderer/components/BottomBar.tsx | 131 ++- src/renderer/components/ScopeModule.tsx | 135 ++- src/renderer/components/ScopePopoutBridge.tsx | 10 +- src/renderer/components/Strip.tsx | 3 - src/renderer/env.d.ts | 14 + src/renderer/popouts/ScopePopoutWindow.tsx | 9 +- src/renderer/stores/settingsStore.ts | 28 + src/renderer/stores/themeStore.ts | 241 ++--- src/renderer/styles/globals.css | 127 +-- src/renderer/visualizers/LUFSMeter.ts | 14 +- src/renderer/visualizers/Oscilloscope.ts | 16 +- src/renderer/visualizers/Spectrogram.ts | 49 +- src/renderer/visualizers/SpectrumAnalyzer.ts | 77 +- src/renderer/visualizers/VUMeter.ts | 57 +- src/renderer/visualizers/Vectorscope.ts | 14 +- src/renderer/visualizers/Waveform.ts | 42 +- src/shared/profileState.ts | 26 +- src/shared/themeState.ts | 922 ++++++++++++++++++ src/types/popout.ts | 22 +- src/types/profile.ts | 20 +- src/types/theme.ts | 199 ++++ test/profile-library.test.ts | 42 +- test/theme-library.test.ts | 115 +++ 30 files changed, 2585 insertions(+), 337 deletions(-) create mode 100644 scripts/run-theme-library-tests.mjs create mode 100644 src/main/themeLibrary.ts create mode 100644 src/shared/themeState.ts create mode 100644 src/types/theme.ts create mode 100644 test/theme-library.test.ts diff --git a/package.json b/package.json index 895e895..77ace5d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit", "test:audio-router": "node scripts/run-audio-router-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", + "test:themes": "node scripts/run-theme-library-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "build:native": "cd native && node-gyp rebuild", "rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"", @@ -64,6 +65,12 @@ "name": "Prism Profile", "description": "Prism shareable profile", "role": "Editor" + }, + { + "ext": "iro", + "name": "Prism Theme", + "description": "Prism shareable theme", + "role": "Editor" } ], "files": [ diff --git a/scripts/run-theme-library-tests.mjs b/scripts/run-theme-library-tests.mjs new file mode 100644 index 0000000..40bfbf9 --- /dev/null +++ b/scripts/run-theme-library-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-theme-library-tests-')) +const bundledTestPath = join(tempDir, 'theme-library.test.mjs') +const entryPoint = join(rootDir, 'test', 'theme-library.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index 5b140ca..489f01f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,8 +12,13 @@ import type { import type { ProfileMenuRequest } from '../types/profileMenu' import type { LegacyProfileMigrationPayload, Profile, ProfileLibrarySnapshot } from '../types/profile' import { SCOPE_KINDS, type ScopeKind } from '../types/scope' +import type { + LegacyThemeMigrationPayload, + ThemeLibrarySnapshot, +} from '../types/theme' import { normalizeProfile } from '../shared/profileState' import { FileBackedProfileLibrary } from './profileLibrary' +import { FileBackedThemeLibrary } from './themeLibrary' let mainWindow: BrowserWindow | null = null let moveInterval: ReturnType | null = null @@ -27,8 +32,10 @@ const popoutBoundsTimers = new Map>() const windowSettingsHeights = new Map() const windowSettingsBottomAnchors = new Map() const pendingProfileOpenPaths: string[] = [] +const pendingThemeOpenPaths: string[] = [] let profileLibrary: FileBackedProfileLibrary | null = null +let themeLibrary: FileBackedThemeLibrary | null = null const WINDOW_DEFAULTS = { width: 900, @@ -49,12 +56,24 @@ function getProfileLibrary(): FileBackedProfileLibrary { profileLibrary = new FileBackedProfileLibrary( join(app.getPath('documents'), 'Prism Profiles'), join(app.getPath('userData'), 'profile-state.json'), + async () => getThemeLibrary().getActiveThemeId(), ) } return profileLibrary } +function getThemeLibrary(): FileBackedThemeLibrary { + if (!themeLibrary) { + themeLibrary = new FileBackedThemeLibrary( + join(app.getPath('documents'), 'Prism Themes'), + join(app.getPath('userData'), 'theme-state.json'), + ) + } + + return themeLibrary +} + function queueProfileOpenPath(filePath: string): void { if (extname(filePath).toLowerCase() !== '.prsm') return @@ -70,12 +89,33 @@ function queueProfileOpenPaths(paths: string[]): void { } } +function queueThemeOpenPath(filePath: string): void { + if (extname(filePath).toLowerCase() !== '.iro') return + + const resolvedPath = resolve(filePath) + if (!pendingThemeOpenPaths.includes(resolvedPath)) { + pendingThemeOpenPaths.push(resolvedPath) + } +} + +function queueThemeOpenPaths(paths: string[]): void { + for (const filePath of paths) { + queueThemeOpenPath(filePath) + } +} + function extractProfilePathsFromArgv(argv: string[]): string[] { return argv .filter((value) => extname(value).toLowerCase() === '.prsm') .map((value) => resolve(value)) } +function extractThemePathsFromArgv(argv: string[]): string[] { + return argv + .filter((value) => extname(value).toLowerCase() === '.iro') + .map((value) => resolve(value)) +} + function focusMainWindow(): void { if (!mainWindow) return if (mainWindow.isMinimized()) { @@ -116,6 +156,31 @@ async function processPendingProfileOpenPaths(): Promise { mainWindow.webContents.send('profiles:external-activated', latestSnapshot) } +async function processPendingThemeOpenPaths(): Promise { + if (pendingThemeOpenPaths.length === 0) return + + const paths = [...pendingThemeOpenPaths] + pendingThemeOpenPaths.length = 0 + + let latestSnapshot: ThemeLibrarySnapshot | null = null + + for (const filePath of paths) { + try { + latestSnapshot = await getThemeLibrary().importThemeFromPath(filePath) + } catch (error) { + dialog.showErrorBox( + 'Could Not Open Theme', + getErrorMessage(error, `Prism could not open ${filePath}.`), + ) + } + } + + if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return + + focusMainWindow() + mainWindow.webContents.send('themes:external-activated', latestSnapshot) +} + function scheduleMainWindowBoundsSave(window: BrowserWindow): void { if (!isMainRendererWindow(window)) return @@ -752,6 +817,60 @@ function setupIPC(): void { return getProfileLibrary().migrateLegacyProfiles(payload) }) + ipcMain.handle('themes:get-snapshot', async () => { + return getThemeLibrary().getSnapshot() + }) + + ipcMain.handle('themes:load', async (_event, id: string) => { + return getThemeLibrary().loadTheme(id) + }) + + ipcMain.handle('themes:rename', async (_event, id: string, name: string) => { + return getThemeLibrary().renameTheme(id, name) + }) + + ipcMain.handle('themes:delete', async (_event, id: string) => { + return getThemeLibrary().deleteTheme(id) + }) + + ipcMain.handle('themes:reload', async () => { + return getThemeLibrary().reloadThemes() + }) + + ipcMain.handle('themes:import-dialog', async () => { + const targetWindow = mainWindow ?? BrowserWindow.getFocusedWindow() ?? undefined + const dialogOptions: OpenDialogOptions = { + properties: ['openFile'], + filters: [ + { + name: 'Prism Themes', + extensions: ['iro'], + }, + ], + } + const result = targetWindow + ? await dialog.showOpenDialog(targetWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled || result.filePaths.length === 0) { + return null + } + + return getThemeLibrary().importThemeFromPath(result.filePaths[0]) + }) + + ipcMain.handle('themes:reveal-folder', async () => { + const folderPath = getThemeLibrary().getThemesDirectory() + const openResult = await shell.openPath(folderPath) + if (openResult) { + throw new Error(openResult) + } + }) + + ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => { + return getThemeLibrary().migrateLegacyTheme(payload) + }) + ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => { const request = normalizeProfileMenuRequest(rawRequest) if (!request) return @@ -914,22 +1033,28 @@ if (!hasSingleInstanceLock) { createMainWindow() setupShortcuts() queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) + queueThemeOpenPaths(extractThemePathsFromArgv(process.argv)) void processPendingProfileOpenPaths() + void processPendingThemeOpenPaths() }) app.on('open-file', (event, filePath) => { event.preventDefault() queueProfileOpenPath(filePath) + queueThemeOpenPath(filePath) if (app.isReady()) { void processPendingProfileOpenPaths() + void processPendingThemeOpenPaths() } }) app.on('second-instance', (_event, argv) => { queueProfileOpenPaths(extractProfilePathsFromArgv(argv)) + queueThemeOpenPaths(extractThemePathsFromArgv(argv)) if (app.isReady()) { focusMainWindow() void processPendingProfileOpenPaths() + void processPendingThemeOpenPaths() } }) } diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts index 70324a2..4a3aef1 100644 --- a/src/main/profileLibrary.ts +++ b/src/main/profileLibrary.ts @@ -9,8 +9,8 @@ import { PROFILE_FILE_VERSION, type LegacyProfileMigrationPayload, type Profile, + type PrismProfileFile, type ProfileLibrarySnapshot, - type PrismProfileFileV1, type PrismProfileLocalStateV1, } from '../types/profile' import type { ScopeKind } from '../types/scope' @@ -45,6 +45,7 @@ export class FileBackedProfileLibrary { constructor( private readonly profilesDir: string, private readonly localStatePath: string, + private readonly resolveDefaultThemeId?: () => Promise, ) {} getProfilesDirectory(): string { @@ -270,7 +271,9 @@ export class FileBackedProfileLibrary { let entries = await this.readManagedEntries(localState) if (!entries.some((entry) => entry.id === DEFAULT_PROFILE_ID)) { + const defaultThemeId = await this.resolveDefaultThemeId?.() ?? null const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) + defaultProfile.themeId = defaultThemeId const defaultPath = await this.writeManagedProfile(entries, DEFAULT_PROFILE_ID, defaultProfile) entries = await this.readManagedEntries({ ...localState, @@ -333,7 +336,7 @@ export class FileBackedProfileLibrary { return entries } - private async readProfileFile(filePath: string): Promise { + private async readProfileFile(filePath: string): Promise { let parsed: unknown try { @@ -346,12 +349,12 @@ export class FileBackedProfileLibrary { throw new Error(`Profile file ${basename(filePath)} must contain an object.`) } - const candidate = parsed as Partial + const candidate = parsed as Partial if (candidate.format !== PROFILE_FILE_FORMAT) { throw new Error(`Unsupported profile format in ${basename(filePath)}.`) } - if (candidate.version !== PROFILE_FILE_VERSION) { + if (candidate.version !== 1 && candidate.version !== PROFILE_FILE_VERSION) { throw new Error(`Unsupported profile version in ${basename(filePath)}.`) } diff --git a/src/main/themeLibrary.ts b/src/main/themeLibrary.ts new file mode 100644 index 0000000..74a8130 --- /dev/null +++ b/src/main/themeLibrary.ts @@ -0,0 +1,384 @@ +import { access, mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + DEFAULT_THEME_ID, + DEFAULT_THEME_NAME, + LEGACY_THEME_MIGRATION_VERSION, + type LegacyThemeMigrationPayload, + type LegacyThemeMigrationResult, + type PrismTheme, + type PrismThemeLocalStateV1, + type ThemeLibrarySnapshot, +} from '../types/theme' +import { + createBundledThemes, + createDefaultTheme, + createEmptyThemeLocalState, + createMigratedAccentTheme, + createTemplateThemeFile, + getDefaultThemeIdForLocalState, + normalizeLegacyThemePayload, + normalizeThemeLocalState, + parseThemeFileContent, + resolveLegacyThemeToPresetId, + serializeThemeFile, +} from '../shared/themeState' + +const THEME_EXTENSION = '.iro' +const TEMPLATE_THEME_FILE_NAME = '_TEMPLATE.iro' + +interface ManagedThemeEntry { + id: string + path: string + theme: PrismTheme +} + +export class FileBackedThemeLibrary { + constructor( + private readonly themesDir: string, + private readonly localStatePath: string, + ) {} + + getThemesDirectory(): string { + return this.themesDir + } + + async getActiveThemeId(): Promise { + const { entries, localState } = await this.loadLibrary() + return localState.activeThemeId && entries.some((entry) => entry.id === localState.activeThemeId) + ? localState.activeThemeId + : (entries[0]?.id ?? null) + } + + async getSnapshot(): Promise { + const { entries, localState } = await this.loadLibrary() + return this.buildSnapshot(entries, localState) + } + + async loadTheme(id: string): Promise { + const { entries, localState } = await this.loadLibrary() + this.findEntry(entries, id) + localState.activeThemeId = id + await this.writeLocalState(localState) + return this.buildSnapshot(entries, localState) + } + + async importThemeFromPath(sourcePath: string): Promise { + const { entries, localState } = await this.loadLibrary() + const resolvedSourcePath = resolve(sourcePath) + const theme = await this.readThemeFile(resolvedSourcePath) + const existingEntry = entries.find((entry) => entry.id === theme.id) ?? null + const insideManagedDirectory = this.isPathInsideDirectory(resolvedSourcePath, this.themesDir) + const currentPath = existingEntry?.path ?? (insideManagedDirectory ? resolvedSourcePath : undefined) + const targetPath = await this.writeManagedTheme(entries, theme.id, theme, currentPath) + + if (insideManagedDirectory && resolvedSourcePath !== targetPath) { + await this.unlinkIfExists(resolvedSourcePath) + } + + localState.activeThemeId = theme.id + await this.writeLocalState(localState) + return this.getSnapshot() + } + + async renameTheme(id: string, name: string): Promise { + if (id === DEFAULT_THEME_ID) { + throw new Error('The default theme cannot be renamed.') + } + + const { entries, localState } = await this.loadLibrary() + const entry = this.findEntry(entries, id) + const normalized = { + ...entry.theme, + name: name.trim() || entry.theme.name, + } + await this.writeManagedTheme(entries, id, normalized, entry.path) + return this.buildSnapshot(await this.readManagedEntries(), localState) + } + + async deleteTheme(id: string): Promise { + if (id === DEFAULT_THEME_ID) { + throw new Error('The default theme cannot be deleted.') + } + + const { entries, localState } = await this.loadLibrary() + const entry = this.findEntry(entries, id) + await unlink(entry.path) + if (localState.activeThemeId === id) { + localState.activeThemeId = DEFAULT_THEME_ID + } + await this.writeLocalState(localState) + return this.getSnapshot() + } + + async reloadThemes(): Promise { + return this.getSnapshot() + } + + async migrateLegacyTheme(payload: LegacyThemeMigrationPayload): Promise { + const { entries, localState } = await this.loadLibrary() + if (localState.migrationVersion >= LEGACY_THEME_MIGRATION_VERSION) { + return { + didMigrate: false, + snapshot: this.buildSnapshot(entries, localState), + } + } + + const normalizedPayload = normalizeLegacyThemePayload(payload) + let nextActiveThemeId = resolveLegacyThemeToPresetId(normalizedPayload) + let didMigrate = false + + if (normalizedPayload.customAccent) { + const migratedTheme = createMigratedAccentTheme(normalizedPayload.customAccent) + if (migratedTheme) { + await this.writeManagedTheme(entries, migratedTheme.id, migratedTheme) + nextActiveThemeId = migratedTheme.id + didMigrate = true + } + } else if (nextActiveThemeId) { + didMigrate = true + } + + localState.migrationVersion = LEGACY_THEME_MIGRATION_VERSION + localState.activeThemeId = nextActiveThemeId && (await this.themeExists(nextActiveThemeId)) + ? nextActiveThemeId + : getDefaultThemeIdForLocalState() + await this.writeLocalState(localState) + + return { + didMigrate, + snapshot: await this.getSnapshot(), + } + } + + private async loadLibrary(): Promise<{ + entries: ManagedThemeEntry[] + localState: PrismThemeLocalStateV1 + }> { + await mkdir(this.themesDir, { recursive: true }) + let localState = await this.readLocalState() + let entries = await this.readManagedEntries() + + if (entries.length === 0) { + for (const theme of createBundledThemes()) { + await this.writeManagedTheme(entries, theme.id, theme) + } + entries = await this.readManagedEntries() + } + + if (!entries.some((entry) => entry.id === DEFAULT_THEME_ID)) { + await this.writeManagedTheme(entries, DEFAULT_THEME_ID, createDefaultTheme()) + entries = await this.readManagedEntries() + } + + await this.ensureTemplateFile() + + if (!localState.activeThemeId || !entries.some((entry) => entry.id === localState.activeThemeId)) { + localState = { + ...localState, + activeThemeId: entries.find((entry) => entry.id === DEFAULT_THEME_ID)?.id ?? entries[0]?.id ?? null, + } + await this.writeLocalState(localState) + } + + return { + entries: this.sortEntries(entries), + localState, + } + } + + private async ensureTemplateFile(): Promise { + const targetPath = resolve(join(this.themesDir, TEMPLATE_THEME_FILE_NAME)) + if (await this.pathExists(targetPath)) { + return + } + await writeFile(targetPath, createTemplateThemeFile(), 'utf8') + } + + private async readManagedEntries(): Promise { + const dirEntries = await readdir(this.themesDir, { withFileTypes: true }) + const themePaths = dirEntries + .filter((entry) => { + if (!entry.isFile()) return false + if (entry.name.startsWith('_')) return false + return extname(entry.name).toLowerCase() === THEME_EXTENSION + }) + .map((entry) => resolve(join(this.themesDir, entry.name))) + .sort((left, right) => left.localeCompare(right)) + + const entries: ManagedThemeEntry[] = [] + const seenIds = new Set() + + for (const filePath of themePaths) { + try { + const theme = await this.readThemeFile(filePath) + if (seenIds.has(theme.id)) { + console.warn(`Skipping duplicate theme id "${theme.id}" in ${basename(filePath)}.`) + continue + } + seenIds.add(theme.id) + entries.push({ + id: theme.id, + path: filePath, + theme, + }) + } catch (error) { + console.warn(`Skipping invalid theme file at ${filePath}:`, error) + } + } + + return entries + } + + private async readThemeFile(filePath: string): Promise { + const content = await readFile(filePath, 'utf8') + return parseThemeFileContent( + content, + this.buildFallbackThemeId(filePath), + basename(filePath, THEME_EXTENSION), + ) + } + + private async readLocalState(): Promise { + try { + const raw = await readFile(this.localStatePath, 'utf8') + return normalizeThemeLocalState(JSON.parse(raw) as unknown) + } catch { + return createEmptyThemeLocalState() + } + } + + private async writeLocalState(state: PrismThemeLocalStateV1): Promise { + await mkdir(dirname(this.localStatePath), { recursive: true }) + await writeFile(this.localStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8') + } + + private async writeManagedTheme( + entries: ManagedThemeEntry[], + id: string, + theme: PrismTheme, + currentPath?: string, + ): Promise { + const nextPath = await this.getManagedThemePath(entries, id, theme.name, currentPath) + const existingPath = currentPath ? resolve(currentPath) : null + + await mkdir(dirname(nextPath), { recursive: true }) + await writeFile(nextPath, serializeThemeFile(theme), 'utf8') + + if (existingPath && existingPath !== nextPath) { + await this.unlinkIfExists(existingPath) + } + + return nextPath + } + + private async getManagedThemePath( + entries: ManagedThemeEntry[], + id: string, + name: string, + currentPath?: string, + ): Promise { + if (id === DEFAULT_THEME_ID) { + const defaultPath = resolve(join(this.themesDir, `${DEFAULT_THEME_NAME}${THEME_EXTENSION}`)) + if (!currentPath || resolve(currentPath) === defaultPath) { + return defaultPath + } + const occupiedByOtherEntry = entries.some((entry) => entry.id !== id && entry.path === defaultPath) + return occupiedByOtherEntry ? resolve(currentPath) : defaultPath + } + + const preferredCurrentPath = currentPath ? resolve(currentPath) : null + const occupiedPaths = new Set(entries.filter((entry) => entry.id !== id).map((entry) => entry.path)) + const baseStem = this.sanitizeFileStem(name) + + let attempt = 0 + while (true) { + const suffix = attempt === 0 ? '' : ` (${attempt + 1})` + const candidatePath = resolve(join(this.themesDir, `${baseStem}${suffix}${THEME_EXTENSION}`)) + if (preferredCurrentPath === candidatePath) { + return candidatePath + } + if (occupiedPaths.has(candidatePath) || await this.pathExists(candidatePath)) { + attempt += 1 + continue + } + return candidatePath + } + } + + private sortEntries(entries: ManagedThemeEntry[]): ManagedThemeEntry[] { + return [...entries].sort((left, right) => { + if (left.id === DEFAULT_THEME_ID) return -1 + if (right.id === DEFAULT_THEME_ID) return 1 + return left.theme.name.localeCompare(right.theme.name) + }) + } + + private buildSnapshot(entries: ManagedThemeEntry[], localState: PrismThemeLocalStateV1): ThemeLibrarySnapshot { + const themes = this.sortEntries(entries).reduce((acc, entry) => { + acc[entry.id] = entry.theme + return acc + }, {} as Record) + + return { + themes, + activeThemeId: localState.activeThemeId && themes[localState.activeThemeId] + ? localState.activeThemeId + : (themes[DEFAULT_THEME_ID] ? DEFAULT_THEME_ID : Object.keys(themes)[0] ?? null), + } + } + + private findEntry(entries: ManagedThemeEntry[], id: string): ManagedThemeEntry { + const entry = entries.find((candidate) => candidate.id === id) + if (!entry) { + throw new Error(`Theme "${id}" was not found.`) + } + return entry + } + + private sanitizeFileStem(name: string): string { + const sanitized = name + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + return sanitized || 'Theme' + } + + private buildFallbackThemeId(filePath: string): string { + const stem = basename(filePath, THEME_EXTENSION) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + return stem ? `theme_${stem}` : `theme_${randomUUID().replace(/-/g, '')}` + } + + private isPathInsideDirectory(candidatePath: string, directoryPath: string): boolean { + const relativePath = relative(resolve(directoryPath), resolve(candidatePath)) + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) + } + + private async themeExists(id: string): Promise { + const entries = await this.readManagedEntries() + return entries.some((entry) => entry.id === id) + } + + private async pathExists(targetPath: string): Promise { + try { + await access(targetPath) + return true + } catch { + return false + } + } + + private async unlinkIfExists(targetPath: string): Promise { + try { + await unlink(targetPath) + } catch { + // Ignore missing files. + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 851cce2..53645df 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,6 +16,11 @@ import type { ProfileLibrarySnapshot, } from '../types/profile' import type { ScopeKind } from '../types/scope' +import type { + LegacyThemeMigrationPayload, + LegacyThemeMigrationResult, + ThemeLibrarySnapshot, +} from '../types/theme' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI @@ -49,6 +54,14 @@ contextBridge.exposeInMainWorld('electronAPI', { importProfileDialog: () => ipcRenderer.invoke('profiles:import-dialog') as Promise, revealProfilesFolder: () => ipcRenderer.invoke('profiles:reveal-folder') as Promise, migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => ipcRenderer.invoke('profiles:migrate-legacy', payload) as Promise, + getThemeSnapshot: () => ipcRenderer.invoke('themes:get-snapshot') as Promise, + loadTheme: (id: string) => ipcRenderer.invoke('themes:load', id) as Promise, + renameTheme: (id: string, name: string) => ipcRenderer.invoke('themes:rename', id, name) as Promise, + deleteTheme: (id: string) => ipcRenderer.invoke('themes:delete', id) as Promise, + reloadThemes: () => ipcRenderer.invoke('themes:reload') as Promise, + importThemeDialog: () => ipcRenderer.invoke('themes:import-dialog') as Promise, + revealThemesFolder: () => ipcRenderer.invoke('themes:reveal-folder') as Promise, + migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => ipcRenderer.invoke('themes:migrate-legacy', payload) as Promise, expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight), collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight), setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight), @@ -125,6 +138,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('profiles:external-activated', handler) return () => ipcRenderer.removeListener('profiles:external-activated', handler) }, + onExternalThemeActivated: (callback: (snapshot: ThemeLibrarySnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: ThemeLibrarySnapshot): void => callback(snapshot) + ipcRenderer.on('themes:external-activated', handler) + return () => ipcRenderer.removeListener('themes:external-activated', handler) + }, onScopePopoutReady: (callback: (kind: ScopeKind) => void) => { const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind) ipcRenderer.on('scope-popout:ready', handler) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f4257c..f888b55 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,6 +6,7 @@ import BottomBar from './components/BottomBar' import ScopePopoutBridge from './components/ScopePopoutBridge' import { useSettingsStore } from './stores/settingsStore' import { useAudioStore } from './stores/audioStore' +import { useThemeStore } from './stores/themeStore' import { SCOPE_KINDS } from '../types/scope' const DEFAULT_SETTINGS_HEIGHT = 400 @@ -20,6 +21,8 @@ export default function App(): JSX.Element { const toggleScope = useSettingsStore((s) => s.toggleScope) const initializeProfiles = useSettingsStore((s) => s.initializeProfiles) const applyExternalProfileSnapshot = useSettingsStore((s) => s.applyExternalProfileSnapshot) + const initializeThemes = useThemeStore((s) => s.initializeThemes) + const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot) // Auto-capture on launch useEffect(() => { @@ -30,14 +33,23 @@ export default function App(): JSX.Element { }, []) useEffect(() => { - void initializeProfiles() + void (async () => { + await initializeThemes() + await initializeProfiles() + })() - const unsubscribe = window.electronAPI.onExternalProfileActivated((snapshot) => { + const unsubscribeProfile = window.electronAPI.onExternalProfileActivated((snapshot) => { applyExternalProfileSnapshot(snapshot) }) + const unsubscribeTheme = window.electronAPI.onExternalThemeActivated((snapshot) => { + applyExternalThemeSnapshot(snapshot) + }) - return unsubscribe - }, [applyExternalProfileSnapshot, initializeProfiles]) + return () => { + unsubscribeProfile() + unsubscribeTheme() + } + }, [applyExternalProfileSnapshot, applyExternalThemeSnapshot, initializeProfiles, initializeThemes]) const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0 ? settingsPanelHeight + bottomBarHeight diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index bc8a95c..2e4cedd 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX } from import { useAudioStore } from '../stores/audioStore' import { usePerformanceStore } from '../stores/performanceStore' import { useSettingsStore } from '../stores/settingsStore' -import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore' +import { useThemeStore } from '../stores/themeStore' import type { ScopeKind } from '../../types/scope' import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' import { SCOPE_KINDS } from '../../types/scope' @@ -39,7 +39,18 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) - const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore() + const themeId = useSettingsStore((s) => s.themeId) + const setThemeId = useSettingsStore((s) => s.setThemeId) + const { + themes, + activeThemeId, + loadTheme, + renameTheme, + deleteTheme, + reloadThemes, + importThemeFromDialog, + showThemesFolder, + } = useThemeStore() const { systemSources, @@ -128,6 +139,32 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) + const themeEntries = Object.entries(themes) + + const handleThemeChange = async (value: string): Promise => { + await loadTheme(value) + setThemeId(value) + } + + const handleRenameTheme = async (): Promise => { + if (!activeThemeId || activeThemeId === 'theme_default') return + const activeTheme = themes[activeThemeId] + if (!activeTheme) return + + const nextName = window.prompt('Rename theme', activeTheme.name)?.trim() + if (!nextName) return + await renameTheme(activeThemeId, nextName) + } + + const handleDeleteTheme = async (): Promise => { + if (!activeThemeId || activeThemeId === 'theme_default') return + const activeTheme = themes[activeThemeId] + if (!activeTheme) return + if (!window.confirm(`Delete "${activeTheme.name}"?`)) return + + await deleteTheme(activeThemeId) + setThemeId(useThemeStore.getState().activeThemeId) + } return (
@@ -161,37 +198,75 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
Theme
- {PRESET_IDS.map((id) => { - const preset = PRESETS[id] - const active = presetId === id && !customAccent - return ( - + + + {activeThemeId && activeThemeId !== 'theme_default' ? ( - )} + ) : null} + {activeThemeId && activeThemeId !== 'theme_default' ? ( + + ) : null} +
+ + {themeId ? 'Saved With Profile' : 'Not Linked'} +
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 9185b50..741a716 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -1,7 +1,18 @@ import { useEffect, useRef, type JSX } from 'react' import type { ScopeKind } from '../../types/scope' import type { ScopeSettings } from '../../types/settings' +import type { + PrismResolvedTheme, + ResolvedLUFSMeterTheme, + ResolvedOscilloscopeTheme, + ResolvedSpectrogramTheme, + ResolvedSpectrumTheme, + ResolvedVectorscopeTheme, + ResolvedVUMeterTheme, + ResolvedWaveformTheme, +} from '../../types/theme' import { useSettingsStore } from '../stores/settingsStore' +import { useThemeStore } from '../stores/themeStore' import { SpectrumAnalyzer, type SpectrumAnalyzerDataSource } from '../visualizers/SpectrumAnalyzer' import { Oscilloscope, type OscilloscopeDataSource } from '../visualizers/Oscilloscope' import { Vectorscope, type VectorscopeDataSource } from '../visualizers/Vectorscope' @@ -11,9 +22,18 @@ import { LUFSMeter, type LUFSMeterDataSource } from '../visualizers/LUFSMeter' import { Waveform, type WaveformDataSource } from '../visualizers/Waveform' import type { FrameScheduler } from '../visualizers/frameScheduler' +type ScopeModuleTheme = + | ResolvedSpectrumTheme + | ResolvedOscilloscopeTheme + | ResolvedVectorscopeTheme + | ResolvedSpectrogramTheme + | ResolvedVUMeterTheme + | ResolvedLUFSMeterTheme + | ResolvedWaveformTheme + interface ScopeModuleProps { scopeKind: ScopeKind - lineColor?: string + theme?: ScopeModuleTheme settings?: ScopeSettings[ScopeKind] frameScheduler?: FrameScheduler dataSource?: @@ -34,14 +54,25 @@ interface Visualizer { setOptions(options: Record): void } -/** Maps settingsStore scope settings to the visualizer's setOptions format */ -function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKind], lineColor: string): Record { - const base = { lineColor } +function getScopeTheme(theme: PrismResolvedTheme, kind: ScopeKind): ScopeModuleTheme { + return theme[kind] as ScopeModuleTheme +} + +function scopeSettingsToOptions( + kind: ScopeKind, + settings: ScopeSettings[ScopeKind], + theme: ScopeModuleTheme, +): Record { switch (kind) { case 'spectrum': { const s = settings as ScopeSettings['spectrum'] + const t = theme as ResolvedSpectrumTheme return { - ...base, + lineColor: t.primary, + gradientColors: t.fillGradient, + heatColors: t.heatColors, + backgroundColor: t.background, + gridColor: t.guides, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, @@ -53,12 +84,30 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi } case 'oscilloscope': { const s = settings as ScopeSettings['oscilloscope'] - return { ...base, pitchLock: s.pitchLock, underfillEnabled: s.underfillEnabled, showGrid: s.showGrid, lineWidth: s.lineWidth } + const t = theme as ResolvedOscilloscopeTheme + return { + lineColor: t.primary, + backgroundColor: t.background, + gridColor: t.guides, + underfillColor: t.fill, + pitchLock: s.pitchLock, + underfillEnabled: s.underfillEnabled, + showGrid: s.showGrid, + lineWidth: s.lineWidth, + } } case 'vectorscope': { const s = settings as ScopeSettings['vectorscope'] + const t = theme as ResolvedVectorscopeTheme return { - ...base, + lineColor: t.primary, + backgroundColor: t.background, + gridColor: t.guides, + bandColors: { + low: t.lowBand, + mid: t.midBand, + high: t.highBand, + }, mode: s.mode, multiband: s.multiband, showGrid: s.showGrid, @@ -68,22 +117,60 @@ function scopeSettingsToOptions(kind: ScopeKind, settings: ScopeSettings[ScopeKi } case 'spectrogram': { const s = settings as ScopeSettings['spectrogram'] - return { ...base, fftSize: s.fftSize, scrollSpeed: s.scrollSpeed, clarityMode: s.clarityMode, scaleMode: s.scaleMode, colorScheme: s.colorScheme } + const t = theme as ResolvedSpectrogramTheme + return { + lineColor: t.primary, + heatColors: t.heatColors, + fftSize: s.fftSize, + scrollSpeed: s.scrollSpeed, + clarityMode: s.clarityMode, + scaleMode: s.scaleMode, + colorScheme: s.colorScheme, + } } case 'vumeter': { const s = settings as ScopeSettings['vumeter'] - return { ...base, mode: s.mode, orientation: s.orientation } + const t = theme as ResolvedVUMeterTheme + return { + lineColor: t.primary, + peakColor: t.peak, + clipColor: t.clip, + scaleColor: t.guides, + labelColor: t.text, + mode: s.mode, + orientation: s.orientation, + } } case 'lufsmeter': { const s = settings as ScopeSettings['lufsmeter'] - return { ...base, mode: s.mode } + const t = theme as ResolvedLUFSMeterTheme + return { + lineColor: t.primary, + targetColor: t.target, + scaleColor: t.guides, + labelColor: t.text, + mode: s.mode, + } } case 'waveform': { const s = settings as ScopeSettings['waveform'] - return { ...base, scrollSpeed: s.scrollSpeed, gainDb: s.gainDb, multiband: s.multiband } + const t = theme as ResolvedWaveformTheme + return { + lineColor: t.primary, + gridMajorColor: t.guides, + gridMinorColor: t.guides, + bandColors: { + low: t.lowBand, + mid: t.midBand, + high: t.highBand, + }, + scrollSpeed: s.scrollSpeed, + gainDb: s.gainDb, + multiband: s.multiband, + } } default: - return base + return {} } } @@ -91,11 +178,11 @@ function createVisualizer( scopeKind: ScopeKind, canvas: HTMLCanvasElement, mySettings: ScopeSettings[ScopeKind], - lineColor: string, + theme: ScopeModuleTheme, frameScheduler?: FrameScheduler, dataSource?: ScopeModuleProps['dataSource'], ): Visualizer | null { - const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), frameScheduler } + const opts = { ...scopeSettingsToOptions(scopeKind, mySettings, theme), frameScheduler } switch (scopeKind) { case 'spectrum': return new SpectrumAnalyzer(canvas, { @@ -139,7 +226,7 @@ function createVisualizer( export default function ScopeModule({ scopeKind, - lineColor = '#38bdf8', + theme, settings, frameScheduler, dataSource, @@ -150,42 +237,42 @@ export default function ScopeModule({ const initializedRef = useRef(false) const storeSettings = useSettingsStore((s) => s.scopeSettings[scopeKind]) + const activeTheme = useThemeStore((s) => s.activeTheme) const mySettings = settings ?? storeSettings + const myTheme = theme ?? getScopeTheme(activeTheme, scopeKind) - // Initialize visualizer useEffect(() => { const canvas = canvasRef.current if (!canvas) return initializedRef.current = false - const viz = createVisualizer(scopeKind, canvas, mySettings, lineColor, frameScheduler, dataSource) + const viz = createVisualizer(scopeKind, canvas, mySettings, myTheme, frameScheduler, dataSource) if (!viz) return visualizerRef.current = viz viz.start() - // Mark as initialized after a frame so the settings effect skips the first run - requestAnimationFrame(() => { initializedRef.current = true }) + requestAnimationFrame(() => { + initializedRef.current = true + }) return () => { viz.dispose() visualizerRef.current = null initializedRef.current = false } - }, [dataSource, frameScheduler, scopeKind]) + }, [dataSource, frameScheduler, myTheme, mySettings, scopeKind]) - // Push settings + lineColor changes to live visualizer (skip initial — constructor already handled it) useEffect(() => { if (!visualizerRef.current || !initializedRef.current) return const opts = { - ...scopeSettingsToOptions(scopeKind, mySettings, lineColor), + ...scopeSettingsToOptions(scopeKind, mySettings, myTheme), frameScheduler, ...(dataSource ? { dataSource } : {}), } visualizerRef.current.setOptions(opts) - }, [dataSource, frameScheduler, lineColor, mySettings, scopeKind]) + }, [dataSource, frameScheduler, mySettings, myTheme, scopeKind]) - // ResizeObserver for DPI-aware canvas sizing useEffect(() => { const container = containerRef.current const canvas = canvasRef.current diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index d9fcba4..42413b5 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -60,7 +60,7 @@ export default function ScopePopoutBridge(): null { const popInScope = useSettingsStore((s) => s.popInScope) const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds) const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings) - const accent = useThemeStore((s) => s.accent) + const activeTheme = useThemeStore((s) => s.activeTheme) const frameTarget = usePerformanceStore((s) => s.frameTarget) const activePopoutKinds = useMemo( @@ -96,12 +96,13 @@ export default function ScopePopoutBridge(): null { const snapshot: ScopePopoutSnapshot = { kind, label: SCOPE_LABELS[kind], - accent, + interfaceTheme: activeTheme.interface, + scopeTheme: activeTheme[kind], settings: scopeSettings[kind], } window.electronAPI.sendScopePopoutSnapshot(snapshot) } - }, [accent, activePopoutKinds, scopeSettings]) + }, [activePopoutKinds, activeTheme, scopeSettings]) useEffect(() => { const sessionState = toPopoutSessionState(audioRouter.getSessionState()) @@ -129,7 +130,8 @@ export default function ScopePopoutBridge(): null { window.electronAPI.sendScopePopoutSnapshot({ kind, label: SCOPE_LABELS[kind], - accent: useThemeStore.getState().accent, + interfaceTheme: useThemeStore.getState().activeTheme.interface, + scopeTheme: useThemeStore.getState().activeTheme[kind], settings: useSettingsStore.getState().scopeSettings[kind], }) window.electronAPI.sendScopePopoutSession(kind, toPopoutSessionState(audioRouter.getSessionState())) diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index b0962c0..e99711c 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX } from 'react' import { useSettingsStore } from '../stores/settingsStore' -import { useThemeStore } from '../stores/themeStore' import { SCOPE_LABELS, type ScopeKind } from '../../types/scope' import type { WindowBounds } from '../../types/popout' import ScopeModule from './ScopeModule' @@ -17,7 +16,6 @@ export default function Strip(): JSX.Element { const moveDockedScope = useSettingsStore((s) => s.moveDockedScope) const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight) const popOutScope = useSettingsStore((s) => s.popOutScope) - const accent = useThemeStore((s) => s.accent) const frameTarget = usePerformanceStore((s) => s.frameTarget) const setDockedRenderFps = usePerformanceStore((s) => s.setDockedRenderFps) const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), []) @@ -262,7 +260,6 @@ export default function Strip(): JSX.Element {
diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 71b6c9a..b3acaca 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -18,6 +18,11 @@ import type { ProfileLibrarySnapshot, } from '../types/profile' import type { ScopeKind } from '../types/scope' +import type { + LegacyThemeMigrationPayload, + LegacyThemeMigrationResult, + ThemeLibrarySnapshot, +} from '../types/theme' declare global { interface Window { @@ -45,6 +50,14 @@ declare global { importProfileDialog: () => Promise revealProfilesFolder: () => Promise migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => Promise + getThemeSnapshot: () => Promise + loadTheme: (id: string) => Promise + renameTheme: (id: string, name: string) => Promise + deleteTheme: (id: string) => Promise + reloadThemes: () => Promise + importThemeDialog: () => Promise + revealThemesFolder: () => Promise + migrateLegacyTheme: (payload: LegacyThemeMigrationPayload) => Promise expandSettings: (panelHeight: number) => void collapseSettings: (panelHeight: number) => void setSettingsHeight: (panelHeight: number) => void @@ -69,6 +82,7 @@ declare global { onProfileMenuImport: (callback: () => void) => () => void onProfileMenuShowFolder: (callback: () => void) => () => void onExternalProfileActivated: (callback: (snapshot: ProfileLibrarySnapshot) => void) => () => void + onExternalThemeActivated: (callback: (snapshot: ThemeLibrarySnapshot) => void) => () => void onScopePopoutReady: (callback: (kind: ScopeKind) => void) => () => void onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => () => void onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => () => void diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index 252a935..6e454e7 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -2,10 +2,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, typ import type { ScopePopoutSnapshot } from '../../types/popout' import { SCOPE_LABELS, type ScopeKind } from '../../types/scope' import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings' +import { applyResolvedThemeToDocument, createDefaultTheme, resolveTheme } from '../../shared/themeState' import ScopeModule from '../components/ScopeModule' import ScopeSettingsSection from '../components/ScopeSettingsSection' import { usePerformanceStore } from '../stores/performanceStore' -import { applyAccentToDOM } from '../stores/themeStore' import { ScopePopoutDataSource } from './ScopePopoutDataSource' import { FrameScheduler } from '../visualizers/frameScheduler' @@ -44,6 +44,7 @@ interface ScopePopoutWindowProps { } const POPOUT_SETTINGS_EXPAND_HEIGHT = 260 +const defaultTheme = resolveTheme(createDefaultTheme()) export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element { const [snapshot, setSnapshot] = useState | null>(null) @@ -61,7 +62,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => { if (nextSnapshot.kind !== scopeKind) return setSnapshot(nextSnapshot) - applyAccentToDOM(nextSnapshot.accent) + applyResolvedThemeToDocument({ interface: nextSnapshot.interfaceTheme }, document.documentElement.style) }) const unsubscribeAudio = window.electronAPI.onScopePopoutAudio((kind, batch) => { if (kind !== scopeKind) return @@ -81,8 +82,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) } }, [dataSource, scopeKind]) - const effectiveAccent = snapshot?.accent ?? '#38bdf8' const effectiveSettings = (snapshot?.settings ?? DEFAULT_SCOPE_SETTINGS[scopeKind]) as ScopeSettings[ScopeKind] + const effectiveScopeTheme = snapshot?.scopeTheme ?? defaultTheme[scopeKind] const settingsHeight = miniSettingsOpen ? POPOUT_SETTINGS_EXPAND_HEIGHT : 0 const handleUpdateScopeSettings = (kind: K, partial: Partial): void => { @@ -215,7 +216,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
@@ -35,6 +37,7 @@ interface PersistedSettingsState { } interface WorkingSettingsState { + themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: Set widthWeights: Record @@ -48,6 +51,7 @@ interface SettingsState extends WorkingSettingsState { activeProfileId: string | null initializeProfiles: () => Promise applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => void + setThemeId: (themeId: string | null) => void toggleScope: (kind: ScopeKind) => void moveDockedScope: (kind: ScopeKind, direction: 'left' | 'right') => void setScopeWidthWeight: (kind: ScopeKind, weight: number) => void @@ -80,6 +84,7 @@ function loadFromStorage(): Partial { function saveToStorage(state: WorkingSettingsState): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ + themeId: state.themeId, scopeOrder: state.scopeOrder, hiddenScopes: Array.from(state.hiddenScopes), widthWeights: state.widthWeights, @@ -121,6 +126,7 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState { const normalizedProfile = normalizeProfile(profile, profile.name) return { + themeId: normalizedProfile.themeId, scopeOrder: normalizeScopeOrder(normalizedProfile.scopeOrder), hiddenScopes: new Set(normalizeHiddenScopes(normalizedProfile.hiddenScopes)), widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights), @@ -147,6 +153,9 @@ function applyProfileSnapshot( } const nextState = createWorkingStateFromProfile(activeProfile) + if (!nextState.themeId && snapshot.activeProfileId === DEFAULT_PROFILE_ID) { + nextState.themeId = useThemeStore.getState().activeThemeId + } saveToStorage(nextState) set({ ...nextState, @@ -154,6 +163,10 @@ function applyProfileSnapshot( activeProfileId: snapshot.activeProfileId, }) + if (activeProfile.themeId && useThemeStore.getState().themes[activeProfile.themeId]) { + void useThemeStore.getState().loadTheme(activeProfile.themeId) + } + if (activeProfile.windowBounds && canUseElectronAPI()) { window.electronAPI.setWindowBounds(activeProfile.windowBounds) } @@ -162,6 +175,7 @@ function applyProfileSnapshot( async function buildProfileFromState(state: SettingsState, name: string): Promise { const profile = normalizeProfile({ name, + themeId: state.themeId ?? useThemeStore.getState().activeThemeId, scopeOrder: [...state.scopeOrder], hiddenScopes: Array.from(state.hiddenScopes), widthWeights: { ...state.widthWeights }, @@ -226,6 +240,9 @@ export function moveDockedScopeOrder( const stored = loadFromStorage() const initialWorkingState: WorkingSettingsState = { + themeId: typeof stored.themeId === 'string' && stored.themeId.trim() + ? stored.themeId.trim() + : null, scopeOrder: normalizeScopeOrder(stored.scopeOrder), hiddenScopes: new Set(normalizeHiddenScopes(stored.hiddenScopes)), widthWeights: normalizeWidthWeights(stored.widthWeights), @@ -266,6 +283,17 @@ export const useSettingsStore = create((set, get) => ({ applyProfileSnapshot(set, snapshot, { loadActiveProfile: true }) }, + setThemeId: (themeId: string | null) => { + set((state) => { + const nextState = { + ...state, + themeId, + } + saveToStorage(nextState) + return nextState + }) + }, + toggleScope: (kind: ScopeKind) => { set((state) => { const next = new Set(state.hiddenScopes) diff --git a/src/renderer/stores/themeStore.ts b/src/renderer/stores/themeStore.ts index 350fa3e..ffc4aaf 100644 --- a/src/renderer/stores/themeStore.ts +++ b/src/renderer/stores/themeStore.ts @@ -1,133 +1,146 @@ import { create } from 'zustand' +import type { + LegacyThemeMigrationPayload, + PrismResolvedTheme, + PrismTheme, + ThemeLibrarySnapshot, +} from '../../types/theme' +import { + applyResolvedThemeToDocument, + createDefaultTheme, + normalizeLegacyThemePayload, + resolveTheme, +} from '../../shared/themeState' -export interface ThemePreset { - name: string - accent: string - accentHover: string - accentGlow: string - accentRgb: string -} - -const PRESETS: Record = { - default: { - name: 'Cyan', - accent: '#38bdf8', - accentHover: '#7dd3fc', - accentGlow: 'rgba(56, 189, 248, 0.3)', - accentRgb: '56, 189, 248', - }, - graphite: { - name: 'Graphite', - accent: '#4fc3f7', - accentHover: '#81d4fa', - accentGlow: 'rgba(79, 195, 247, 0.3)', - accentRgb: '79, 195, 247', - }, - midnight: { - name: 'Midnight', - accent: '#4f9bff', - accentHover: '#7eb8ff', - accentGlow: 'rgba(79, 155, 255, 0.3)', - accentRgb: '79, 155, 255', - }, - green: { - name: 'Green', - accent: '#4ade80', - accentHover: '#86efac', - accentGlow: 'rgba(74, 222, 128, 0.3)', - accentRgb: '74, 222, 128', - }, - purple: { - name: 'Purple', - accent: '#a78bfa', - accentHover: '#c4b5fd', - accentGlow: 'rgba(167, 139, 250, 0.3)', - accentRgb: '167, 139, 250', - }, - rose: { - name: 'Rose', - accent: '#fb7185', - accentHover: '#fda4af', - accentGlow: 'rgba(251, 113, 133, 0.3)', - accentRgb: '251, 113, 133', - }, -} - -export const PRESET_IDS = Object.keys(PRESETS) - -const STORAGE_KEY = 'prism:theme' - -function hexToRgb(hex: string): string { - const h = hex.replace('#', '') - const r = parseInt(h.substring(0, 2), 16) - const g = parseInt(h.substring(2, 4), 16) - const b = parseInt(h.substring(4, 6), 16) - return `${r}, ${g}, ${b}` -} - -function lightenHex(hex: string, amount: number): string { - const h = hex.replace('#', '') - const r = Math.min(255, parseInt(h.substring(0, 2), 16) + amount) - const g = Math.min(255, parseInt(h.substring(2, 4), 16) + amount) - const b = Math.min(255, parseInt(h.substring(4, 6), 16) + amount) - return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` -} +const LEGACY_STORAGE_KEY = 'prism:theme' interface ThemeState { - presetId: string - customAccent: string | null // null = use preset accent - accent: string // resolved accent hex - - setPreset: (id: string) => void - setCustomAccent: (hex: string | null) => void + themes: Record + activeThemeId: string | null + activeTheme: PrismResolvedTheme + accent: string + initializeThemes: () => Promise + applyExternalThemeSnapshot: (snapshot: ThemeLibrarySnapshot) => void + loadTheme: (id: string) => Promise + renameTheme: (id: string, name: string) => Promise + deleteTheme: (id: string) => Promise + reloadThemes: () => Promise + importThemeFromDialog: () => Promise + showThemesFolder: () => Promise } -function loadTheme(): { presetId: string; customAccent: string | null } { +function canUseElectronAPI(): boolean { + return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined' +} + +function loadLegacyThemeMigrationPayload(): LegacyThemeMigrationPayload | null { try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) - } catch { /* ignore */ } - return { presetId: 'default', customAccent: null } + const raw = localStorage.getItem(LEGACY_STORAGE_KEY) + if (!raw) return null + return normalizeLegacyThemePayload(JSON.parse(raw) as unknown) + } catch { + return null + } } -export function applyAccentToDOM(accent: string): void { - const rgb = hexToRgb(accent) - const root = document.documentElement - root.style.setProperty('--accent', accent) - root.style.setProperty('--accent-hover', lightenHex(accent, 50)) - root.style.setProperty('--accent-glow', `rgba(${rgb}, 0.3)`) - root.style.setProperty('--accent-rgb', rgb) +function clearLegacyThemeStorage(): void { + try { + localStorage.removeItem(LEGACY_STORAGE_KEY) + } catch { + // Ignore localStorage failures. + } } -const stored = loadTheme() -const initialPreset = PRESETS[stored.presetId] ?? PRESETS.default -const initialAccent = stored.customAccent ?? initialPreset.accent +function applyThemeToDOM(theme: PrismResolvedTheme): void { + if (typeof document === 'undefined') return + applyResolvedThemeToDocument(theme, document.documentElement.style) +} -// Apply immediately on load -applyAccentToDOM(initialAccent) +function resolveActiveTheme(snapshot: ThemeLibrarySnapshot): PrismResolvedTheme { + const theme = snapshot.activeThemeId + ? snapshot.themes[snapshot.activeThemeId] ?? null + : null + return resolveTheme(theme ?? createDefaultTheme()) +} + +function applyThemeSnapshot( + set: (partial: Partial) => void, + snapshot: ThemeLibrarySnapshot, +): void { + const activeTheme = resolveActiveTheme(snapshot) + applyThemeToDOM(activeTheme) + set({ + themes: snapshot.themes, + activeThemeId: snapshot.activeThemeId, + activeTheme, + accent: activeTheme.interface.accent, + }) +} + +const fallbackTheme = resolveTheme(createDefaultTheme()) +applyThemeToDOM(fallbackTheme) export const useThemeStore = create((set) => ({ - presetId: stored.presetId, - customAccent: stored.customAccent, - accent: initialAccent, + themes: { + [fallbackTheme.id]: createDefaultTheme(), + }, + activeThemeId: fallbackTheme.id, + activeTheme: fallbackTheme, + accent: fallbackTheme.interface.accent, - setPreset: (id: string) => { - const preset = PRESETS[id] ?? PRESETS.default - applyAccentToDOM(preset.accent) - const state = { presetId: id, customAccent: null, accent: preset.accent } - localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: id, customAccent: null })) - set(state) + initializeThemes: async () => { + if (!canUseElectronAPI()) return + + let snapshot = await window.electronAPI.getThemeSnapshot() + const legacyPayload = loadLegacyThemeMigrationPayload() + if (legacyPayload) { + const migration = await window.electronAPI.migrateLegacyTheme(legacyPayload) + if (migration.didMigrate) { + snapshot = migration.snapshot + } + clearLegacyThemeStorage() + } + + applyThemeSnapshot(set, snapshot) }, - setCustomAccent: (hex: string | null) => { - set((prev) => { - const preset = PRESETS[prev.presetId] ?? PRESETS.default - const accent = hex ?? preset.accent - applyAccentToDOM(accent) - localStorage.setItem(STORAGE_KEY, JSON.stringify({ presetId: prev.presetId, customAccent: hex })) - return { ...prev, customAccent: hex, accent } - }) + applyExternalThemeSnapshot: (snapshot) => { + applyThemeSnapshot(set, snapshot) + }, + + loadTheme: async (id: string) => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.loadTheme(id) + applyThemeSnapshot(set, snapshot) + }, + + renameTheme: async (id: string, name: string) => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.renameTheme(id, name) + applyThemeSnapshot(set, snapshot) + }, + + deleteTheme: async (id: string) => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.deleteTheme(id) + applyThemeSnapshot(set, snapshot) + }, + + reloadThemes: async () => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.reloadThemes() + applyThemeSnapshot(set, snapshot) + }, + + importThemeFromDialog: async () => { + if (!canUseElectronAPI()) return + const snapshot = await window.electronAPI.importThemeDialog() + if (!snapshot) return + applyThemeSnapshot(set, snapshot) + }, + + showThemesFolder: async () => { + if (!canUseElectronAPI()) return + await window.electronAPI.revealThemesFolder() }, })) - -export { PRESETS } diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index eee5aa3..140d516 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -19,12 +19,29 @@ --text-tertiary: rgba(255, 255, 255, 0.42); --text-muted: rgba(255, 255, 255, 0.3); --danger: #f87171; + --warning: #ffbf00; --success: #22c55e; --accent: #38bdf8; --accent-hover: #7dd3fc; --accent-glow: rgba(56, 189, 248, 0.3); --accent-rgb: 56, 189, 248; + --toolbar-bg: rgba(0, 0, 0, 0.74); + --settings-bg-top: rgba(8, 10, 14, 0.94); + --settings-bg-bottom: rgba(4, 6, 9, 0.98); + --bottom-bar-bg: rgba(2, 4, 7, 0.98); + --menu-bg: rgba(8, 11, 16, 0.96); + --menu-border: rgba(255, 255, 255, 0.1); + --control-bg: rgba(255, 255, 255, 0.03); + --control-bg-hover: rgba(255, 255, 255, 0.06); + --control-bg-active: rgba(var(--accent-rgb), 0.12); + --control-border: rgba(255, 255, 255, 0.08); + --control-border-active: rgba(var(--accent-rgb), 0.28); + --input-bg: rgba(10, 14, 20, 0.96); + --input-bg-focus: rgba(12, 17, 24, 0.98); + --input-border: rgba(255, 255, 255, 0.09); + --input-border-focus: rgba(var(--accent-rgb), 0.26); + --divider: rgba(255, 255, 255, 0.08); } * { @@ -86,9 +103,9 @@ select { flex-direction: column; overflow: hidden; background: - linear-gradient(180deg, rgba(8, 10, 14, 0.94), rgba(4, 6, 9, 0.98)), + linear-gradient(180deg, var(--settings-bg-top), var(--settings-bg-bottom)), rgba(0, 0, 0, 0.96); - border-top: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid var(--divider); box-shadow: 0 -16px 34px rgba(0, 0, 0, 0.34); } @@ -99,7 +116,7 @@ select { min-height: 38px; padding: 4px 10px 0; gap: 10px; - background: rgba(0, 0, 0, 0.74); + background: var(--toolbar-bg); backdrop-filter: blur(16px) saturate(1.05); -webkit-backdrop-filter: blur(16px) saturate(1.05); } @@ -113,7 +130,7 @@ select { padding: 0; border: 0; background: transparent; - color: rgba(255, 255, 255, 0.3); + color: var(--text-muted); cursor: grab; flex-shrink: 0; -webkit-app-region: no-drag; @@ -136,8 +153,8 @@ select { min-height: 28px; padding: 0 10px; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--control-border); + background: var(--glass-bg); } .toolbar__brand-mark { @@ -164,7 +181,7 @@ select { } .toolbar__brand-text { - color: rgba(255, 255, 255, 0.72); + color: var(--text-secondary); font-size: 10px; } @@ -181,7 +198,7 @@ select { min-height: 28px; padding: 0 10px; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--control-border); background: transparent; color: var(--text-secondary); font-family: 'JetBrains Mono', monospace; @@ -195,8 +212,8 @@ select { .toolbar__profile-button:hover, .toolbar__profile-button.is-active { - color: rgba(255, 255, 255, 0.78); - border-color: rgba(255, 255, 255, 0.14); + color: var(--text-primary); + border-color: var(--control-border-active); } .toolbar__profile-name { @@ -224,8 +241,8 @@ select { margin-top: 4px; display: flex; flex-direction: column; - background: rgba(8, 11, 16, 0.96); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--menu-bg); + border: 1px solid var(--menu-border); border-radius: 8px; overflow: hidden; z-index: 20; @@ -248,7 +265,7 @@ select { .toolbar__reposition-option:hover { color: var(--text-primary); - background: rgba(255, 255, 255, 0.06); + background: var(--control-bg-hover); } .toolbar__chip { @@ -578,12 +595,12 @@ select { align-items: center; justify-content: space-between; gap: 10px; - color: rgba(255, 255, 255, 0.56); + color: var(--text-secondary); font-size: 9px; } .settings-control__value { - color: rgba(255, 255, 255, 0.82); + color: var(--text-primary); letter-spacing: 0.08em; } @@ -592,10 +609,10 @@ select { min-height: 34px; padding: 0 12px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.09); - background: linear-gradient(180deg, rgba(10, 14, 20, 0.96), rgba(5, 8, 12, 0.96)); + border: 1px solid var(--input-border); + background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.9); + color: var(--text-primary); font-size: 11px; outline: none; transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease; @@ -603,8 +620,8 @@ select { .settings-control__select:hover, .settings-control__select:focus { - border-color: rgba(var(--accent-rgb), 0.26); - background: linear-gradient(180deg, rgba(12, 17, 24, 0.98), rgba(7, 10, 15, 0.98)); + border-color: var(--input-border-focus); + background: linear-gradient(180deg, var(--input-bg-focus), var(--settings-bg-bottom)); color: var(--text-primary); } @@ -718,8 +735,8 @@ select { padding: 0 11px; border-radius: 9px; border: 1px solid transparent; - background: rgba(255, 255, 255, 0.015); - color: rgba(255, 255, 255, 0.5); + background: var(--control-bg); + color: var(--text-secondary); font-size: 10px; white-space: nowrap; cursor: pointer; @@ -730,9 +747,9 @@ select { } .settings-chip:hover { - color: rgba(255, 255, 255, 0.86); - border-color: rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.04); + color: var(--text-primary); + border-color: var(--control-border); + background: var(--control-bg-hover); } .settings-chip.is-active { @@ -749,9 +766,9 @@ select { min-height: 32px; padding: 0 10px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: linear-gradient(180deg, rgba(10, 14, 19, 0.94), rgba(7, 10, 14, 0.94)); - color: rgba(255, 255, 255, 0.62); + border: 1px solid var(--control-border); + background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom)); + color: var(--text-secondary); font-size: 10px; white-space: nowrap; } @@ -760,7 +777,7 @@ select { width: 6px; height: 6px; border-radius: 999px; - background: rgba(255, 255, 255, 0.26); + background: var(--text-muted); } .settings-status-pill.is-connecting .settings-status-pill__dot { @@ -780,7 +797,7 @@ select { .settings-error-text { margin-top: 8px; - color: rgba(248, 113, 113, 0.88); + color: var(--danger); font-size: 11px; line-height: 1.4; } @@ -840,9 +857,9 @@ select { min-height: 34px; padding: 0 14px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: linear-gradient(180deg, rgba(10, 14, 20, 0.96), rgba(5, 8, 12, 0.96)); - color: rgba(255, 255, 255, 0.66); + border: 1px solid var(--control-border); + background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom)); + color: var(--text-secondary); font-size: 9px; cursor: pointer; transition: @@ -852,9 +869,9 @@ select { } .settings-panel__close:hover { - color: rgba(255, 255, 255, 0.88); - border-color: rgba(var(--accent-rgb), 0.24); - background: linear-gradient(180deg, rgba(12, 17, 24, 0.98), rgba(7, 10, 15, 0.98)); + color: var(--text-primary); + border-color: var(--control-border-active); + background: linear-gradient(180deg, var(--input-bg-focus), var(--settings-bg-bottom)); } .bottom-bar { @@ -863,8 +880,8 @@ select { gap: 0; min-height: 92px; padding: 0; - background: rgba(2, 4, 7, 0.98); - border-top: 1px solid rgba(255, 255, 255, 0.06); + background: var(--bottom-bar-bg); + border-top: 1px solid var(--divider); flex-shrink: 0; } @@ -925,7 +942,7 @@ select { font-family: 'JetBrains Mono', monospace; letter-spacing: 0.12em; text-transform: uppercase; - color: rgba(255, 255, 255, 0.54); + color: var(--text-secondary); font-size: 9px; white-space: nowrap; } @@ -942,8 +959,8 @@ select { gap: 4px; padding: 4px; border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.025); + border: 1px solid var(--control-border); + background: var(--glass-bg); } .bottom-bar__inline--theme { @@ -958,14 +975,14 @@ select { width: 1px; align-self: stretch; margin: 0 16px 0 0; - background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.14) 18%, rgba(255, 255, 255, 0.04) 82%, transparent); + background: linear-gradient(180deg, transparent, var(--divider) 18%, rgba(255, 255, 255, 0.04) 82%, transparent); flex-shrink: 0; } .bottom-bar__trim-value { font-family: 'JetBrains Mono', monospace; font-size: 10px; - color: rgba(255, 255, 255, 0.88); + color: var(--text-primary); letter-spacing: 0.08em; min-width: 56px; text-align: right; @@ -985,7 +1002,7 @@ select { .bottom-bar__fps-pill { min-width: 84px; justify-content: center; - color: rgba(255, 255, 255, 0.84); + color: var(--text-primary); font-family: 'JetBrains Mono', monospace; letter-spacing: 0.08em; } @@ -1000,8 +1017,8 @@ select { align-items: center; flex: 0 0 auto; padding: 10px 14px 10px 16px; - border-left: 1px solid rgba(255, 255, 255, 0.08); - background: linear-gradient(90deg, rgba(2, 4, 7, 0.76), rgba(2, 4, 7, 0.98) 24%, rgba(2, 4, 7, 1)); + border-left: 1px solid var(--divider); + background: linear-gradient(90deg, rgba(2, 4, 7, 0.76), var(--bottom-bar-bg) 24%, var(--bottom-bar-bg)); } .bottom-bar__close { @@ -1015,7 +1032,7 @@ select { flex-direction: column; position: relative; overflow: hidden; - background: #000000; + background: var(--bg-primary); color: var(--text-primary); } @@ -1055,7 +1072,7 @@ select { gap: 10px; min-height: 42px; padding: 8px 10px; - background: rgba(7, 10, 14, 0.92); + background: var(--toolbar-bg); } .scope-popout__drag { @@ -1092,7 +1109,7 @@ select { display: inline-flex; align-items: center; justify-content: center; - color: rgba(255, 255, 255, 0.32); + color: var(--text-muted); } .scope-popout__drag-icon svg { @@ -1134,9 +1151,9 @@ select { align-items: center; justify-content: center; border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.03); - color: rgba(255, 255, 255, 0.64); + border: 1px solid var(--control-border); + background: var(--control-bg); + color: var(--text-secondary); cursor: pointer; transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease, transform 120ms ease; } @@ -1149,8 +1166,8 @@ select { .scope-popout__button:hover, .scope-popout__button.is-active { color: var(--accent); - border-color: rgba(var(--accent-rgb), 0.28); - background: rgba(var(--accent-rgb), 0.1); + border-color: var(--control-border-active); + background: var(--control-bg-active); transform: translateY(-1px); } @@ -1158,7 +1175,7 @@ select { height: 100%; overflow-y: auto; overflow-x: hidden; - background: rgba(5, 7, 10, 0.98); + background: var(--settings-bg-bottom); padding: 12px 0 10px; } diff --git a/src/renderer/visualizers/LUFSMeter.ts b/src/renderer/visualizers/LUFSMeter.ts index 652ce3b..3ead42c 100644 --- a/src/renderer/visualizers/LUFSMeter.ts +++ b/src/renderer/visualizers/LUFSMeter.ts @@ -12,6 +12,9 @@ export interface LUFSMeterDataSource extends VisualizerSessionSource { export interface LUFSMeterOptions { mode?: LUFSMeterMode lineColor?: string + targetColor?: string + scaleColor?: string + labelColor?: string dataSource?: LUFSMeterDataSource frameScheduler?: FrameScheduler } @@ -21,6 +24,9 @@ type ResolvedLUFSMeterOptions = Required this.dataSource.isPlaying(), @@ -342,6 +350,7 @@ export class Spectrogram { const { dataSource, frameScheduler: _frameScheduler, ...optionUpdates } = options const previousOptions = this.options this.options = resolveOptions(previousOptions, optionUpdates) + this.heatLut = buildHeatLUT(this.options.heatColors) if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource @@ -525,9 +534,9 @@ export class Spectrogram { const dataIndex = row * 4 if (this.options.colorScheme === 'heat') { - imageData[dataIndex] = HEAT_LUT[lutIndex * 3] - imageData[dataIndex + 1] = HEAT_LUT[(lutIndex * 3) + 1] - imageData[dataIndex + 2] = HEAT_LUT[(lutIndex * 3) + 2] + imageData[dataIndex] = this.heatLut[lutIndex * 3] + imageData[dataIndex + 1] = this.heatLut[(lutIndex * 3) + 1] + imageData[dataIndex + 2] = this.heatLut[(lutIndex * 3) + 2] } else { imageData[dataIndex] = Math.round(tintR * intensity) imageData[dataIndex + 1] = Math.round(tintG * intensity) diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index f844b9a..341926e 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -3,6 +3,7 @@ import { spectrum as nativeSpectrum, isNativeAvailable } from '../audio/native' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' +import { resolveColorToRgb } from '../utils/color' import { DEFAULT_SPECTRUM_TILT_DB_PER_OCTAVE, DEFAULT_SPECTRUM_HEATMAP_TILT_DB_PER_OCTAVE, @@ -20,6 +21,7 @@ export interface SpectrumAnalyzerOptions { fillGradient?: boolean heatmapFill?: boolean gradientColors?: string[] + heatColors?: [string, string, string] backgroundColor?: string showGrid?: boolean gridColor?: string @@ -40,26 +42,59 @@ export interface SpectrumAnalyzerOptions { type ResolvedSpectrumAnalyzerOptions = Required> type HeatStop = { at: number; color: [number, number, number] } -const HEAT_STOPS: readonly HeatStop[] = [ - { at: 0, color: [0, 0, 0] }, - { at: 0.14, color: [15, 7, 33] }, - { at: 0.32, color: [61, 11, 94] }, - { at: 0.54, color: [163, 26, 121] }, - { at: 0.74, color: [255, 82, 87] }, - { at: 0.9, color: [255, 166, 63] }, - { at: 1, color: [255, 241, 209] }, +const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [ + 'rgb(15, 7, 33)', + 'rgb(163, 26, 121)', + 'rgb(255, 241, 209)', ] -function buildHeatLUT(): Uint8Array { +function isLegacyDefaultHeatColors(colors: [string, string, string]): boolean { + return colors.every((color, index) => { + const left = resolveColorToRgb(color) + const right = resolveColorToRgb(LEGACY_DEFAULT_HEAT_COLORS[index]) + return left.r === right.r && left.g === right.g && left.b === right.b + }) +} + +function buildHeatStops(colors: [string, string, string]): HeatStop[] { + if (isLegacyDefaultHeatColors(colors)) { + // Preserve Prism's original default spectrum heatmap instead of flattening it + // into the generic themed stop builder. + return [ + { at: 0, color: [0, 0, 0] }, + { at: 0.14, color: [15, 7, 33] }, + { at: 0.32, color: [61, 11, 94] }, + { at: 0.54, color: [163, 26, 121] }, + { at: 0.74, color: [255, 82, 87] }, + { at: 0.9, color: [255, 166, 63] }, + { at: 1, color: [255, 241, 209] }, + ] + } + + const low = resolveColorToRgb(colors[0]) + const mid = resolveColorToRgb(colors[1]) + const high = resolveColorToRgb(colors[2]) + + return [ + { at: 0, color: [0, 0, 0] }, + { at: 0.2, color: [Math.round(low.r * 0.5), Math.round(low.g * 0.5), Math.round(low.b * 0.5)] }, + { at: 0.48, color: [low.r, low.g, low.b] }, + { at: 0.76, color: [mid.r, mid.g, mid.b] }, + { at: 1, color: [high.r, high.g, high.b] }, + ] +} + +function buildHeatLUT(colors: [string, string, string]): Uint8Array { + const heatStops = buildHeatStops(colors) const lut = new Uint8Array(256 * 3) for (let i = 0; i < 256; i++) { const t = i / 255 - let s = HEAT_STOPS[0] - let e = HEAT_STOPS[HEAT_STOPS.length - 1] - for (let si = 0; si < HEAT_STOPS.length - 1; si++) { - if (t <= HEAT_STOPS[si + 1].at) { - s = HEAT_STOPS[si] - e = HEAT_STOPS[si + 1] + let s = heatStops[0] + let e = heatStops[heatStops.length - 1] + for (let si = 0; si < heatStops.length - 1; si++) { + if (t <= heatStops[si + 1].at) { + s = heatStops[si] + e = heatStops[si + 1] break } } @@ -70,8 +105,6 @@ function buildHeatLUT(): Uint8Array { } return lut } - -const HEAT_LUT = buildHeatLUT() const HEATMAP_GAMMA = 1.4 const defaultOptions: ResolvedSpectrumAnalyzerOptions = { @@ -80,6 +113,7 @@ const defaultOptions: ResolvedSpectrumAnalyzerOptions = { fillGradient: true, heatmapFill: false, gradientColors: ['rgba(0, 255, 255, 0)', 'rgba(0, 255, 255, 0.3)', 'rgba(138, 43, 226, 0.5)'], + heatColors: [...LEGACY_DEFAULT_HEAT_COLORS], backgroundColor: 'transparent', showGrid: true, gridColor: 'rgba(255, 255, 255, 0.1)', @@ -109,6 +143,7 @@ export class SpectrumAnalyzer { private nativeInitialized = false private sampleRate = 48000 private lastSampleRate = 0 + private heatLut: Uint8Array private staticLayerCanvas: HTMLCanvasElement private staticLayerCtx: CanvasRenderingContext2D private staticLayerKey = '' @@ -132,6 +167,7 @@ export class SpectrumAnalyzer { ), } this.dataSource = dataSource ?? defaultSpectrumDataSource + this.heatLut = buildHeatLUT(this.options.heatColors) this.frameLoop = new VisualizerFrameLoop({ frameScheduler, shouldRun: () => this.dataSource.isPlaying(), @@ -205,6 +241,7 @@ export class SpectrumAnalyzer { nextOptions.heatmapTiltDbPerOctave = clampSpectrumHeatmapTiltDbPerOctave(optionUpdates.heatmapTiltDbPerOctave) } this.options = nextOptions + this.heatLut = buildHeatLUT(this.options.heatColors) if (dataSource && dataSource !== this.dataSource) { this.dataSource = dataSource this.subscribeToSessionChanges() @@ -400,9 +437,9 @@ export class SpectrumAnalyzer { const intensity = points[i].heatmapIntensity const li = Math.round(intensity * 255) - const r = HEAT_LUT[li * 3] - const g = HEAT_LUT[li * 3 + 1] - const b = HEAT_LUT[li * 3 + 2] + const r = this.heatLut[li * 3] + const g = this.heatLut[li * 3 + 1] + const b = this.heatLut[li * 3 + 2] ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)` ctx.fillRect(x, Math.floor(y), colWidth, Math.ceil(fillHeight)) diff --git a/src/renderer/visualizers/VUMeter.ts b/src/renderer/visualizers/VUMeter.ts index ec237db..e170ea6 100644 --- a/src/renderer/visualizers/VUMeter.ts +++ b/src/renderer/visualizers/VUMeter.ts @@ -23,6 +23,10 @@ export interface VUMeterOptions { mode?: VUMeterMode orientation?: VUMeterOrientation lineColor?: string + peakColor?: string + clipColor?: string + scaleColor?: string + labelColor?: string dataSource?: VUMeterDataSource frameScheduler?: FrameScheduler } @@ -33,6 +37,10 @@ const defaultOptions: ResolvedVUMeterOptions = { mode: 'bar', orientation: DEFAULT_VU_METER_ORIENTATION, lineColor: '#38bdf8', + peakColor: 'rgb(255, 127, 0)', + clipColor: 'rgba(255, 120, 80, 0.9)', + scaleColor: 'rgba(255, 255, 255, 0.12)', + labelColor: 'rgba(255, 255, 255, 0.5)', } const defaultVUMeterDataSource: VUMeterDataSource = { @@ -44,6 +52,11 @@ function colorWithAlpha(r: number, g: number, b: number, a: number): string { return `rgba(${r}, ${g}, ${b}, ${a})` } +function alphaColor(color: string, alpha: number): string { + const { r, g, b } = resolveColorToRgb(color) + return colorWithAlpha(r, g, b, alpha) +} + // ---- VU Meter class ---- export class VUMeter { @@ -245,7 +258,7 @@ export class VUMeter { const hotThreshold = this.dbToNormalized(-6) * w // Background track - ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25) ctx.fillRect(x, y, w, h) // Main level bar @@ -272,13 +285,13 @@ export class VUMeter { const peakX = x + peakNorm * w const peakInHot = peakDb > -6 ctx.fillStyle = peakInHot - ? 'rgba(255, 120, 80, 0.9)' - : colorWithAlpha(cr, cg, cb, 0.9) + ? this.options.clipColor + : this.options.peakColor ctx.fillRect(peakX - 1, y, 2, h) } // Scale ticks - ctx.fillStyle = 'rgba(255, 255, 255, 0.12)' + ctx.fillStyle = this.options.scaleColor const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0] for (const db of tickDbs) { const tickX = x + this.dbToNormalized(db) * w @@ -297,7 +310,7 @@ export class VUMeter { const levelHeight = levelNorm * h const hotThreshold = this.dbToNormalized(-6) * h - ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25) ctx.fillRect(x, y, w, h) if (levelHeight > 0) { @@ -321,12 +334,12 @@ export class VUMeter { const peakY = y + h - peakNorm * h const peakInHot = peakDb > -6 ctx.fillStyle = peakInHot - ? 'rgba(255, 120, 80, 0.9)' - : colorWithAlpha(cr, cg, cb, 0.9) + ? this.options.clipColor + : this.options.peakColor ctx.fillRect(x, peakY - 1, w, 2) } - ctx.fillStyle = 'rgba(255, 255, 255, 0.1)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.84) const tickDbs = [-48, -36, -24, -18, -12, -6, -3, 0] for (const db of tickDbs) { const tickY = y + h - this.dbToNormalized(db) * h @@ -339,7 +352,7 @@ export class VUMeter { x: number, y: number, w: number, h: number, label: string ): void { - ctx.fillStyle = 'rgba(255, 255, 255, 0.5)' + ctx.fillStyle = this.options.labelColor ctx.font = `${Math.min(22, Math.max(10, h * 0.65))}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -353,7 +366,7 @@ export class VUMeter { ): void { const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` - ctx.fillStyle = 'rgba(255, 255, 255, 0.4)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.8) ctx.font = `${Math.min(20, Math.max(9, h * 0.55))}px "JetBrains Mono", monospace` ctx.textAlign = 'left' ctx.textBaseline = 'middle' @@ -367,7 +380,7 @@ export class VUMeter { ): void { const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, db)) const text = displayDb <= VU_METER_MIN_DB + 1 ? '-∞' : `${displayDb.toFixed(1)}` - ctx.fillStyle = 'rgba(255, 255, 255, 0.4)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.8) ctx.font = `${Math.min(16, Math.max(8, h * 0.5))}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -383,11 +396,11 @@ export class VUMeter { const corr = Math.max(-1, Math.min(1, this.correlation)) // Background track - ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' + ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25) ctx.fillRect(x, y, w, h) // Center line - ctx.fillStyle = 'rgba(255, 255, 255, 0.12)' + ctx.fillStyle = this.options.scaleColor ctx.fillRect(centerX - 0.5, y, 1, h) // Correlation indicator @@ -399,7 +412,7 @@ export class VUMeter { ctx.fillRect(centerX, y, indicatorWidth, h) } else { // Negative correlation: draw leftward from center (out of phase) - ctx.fillStyle = 'rgba(255, 120, 80, 0.6)' + ctx.fillStyle = alphaColor(this.options.clipColor, 0.6) ctx.fillRect(centerX - indicatorWidth, y, indicatorWidth, h) } } @@ -408,7 +421,7 @@ export class VUMeter { const fontSize = Math.min(18, Math.max(8, h * 0.55)) ctx.font = `${fontSize}px "JetBrains Mono", monospace` ctx.textBaseline = 'middle' - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.6) ctx.textAlign = 'left' ctx.fillText('-1', x + 2, y + h / 2) ctx.textAlign = 'center' @@ -455,7 +468,7 @@ export class VUMeter { const endAngle = Math.PI * 1.75 // 315° (bottom-right) // Scale arc - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)' + ctx.strokeStyle = alphaColor(this.options.scaleColor, 0.66) ctx.lineWidth = 2 ctx.beginPath() ctx.arc(centerX, arcCenterY, arcRadius, startAngle, endAngle) @@ -470,8 +483,8 @@ export class VUMeter { const outerR = arcRadius + 2 ctx.strokeStyle = db >= -6 - ? 'rgba(255, 120, 80, 0.3)' - : 'rgba(255, 255, 255, 0.15)' + ? alphaColor(this.options.clipColor, 0.3) + : alphaColor(this.options.scaleColor, 0.9) ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(centerX + Math.cos(angle) * innerR, arcCenterY + Math.sin(angle) * innerR) @@ -507,8 +520,8 @@ export class VUMeter { const peakAngle = startAngle + peakNorm * (endAngle - startAngle) const peakInHot = peakDb > -6 ctx.fillStyle = peakInHot - ? 'rgba(255, 120, 80, 0.8)' - : colorWithAlpha(cr, cg, cb, 0.8) + ? alphaColor(this.options.clipColor, 0.8) + : alphaColor(this.options.peakColor, 0.8) ctx.beginPath() ctx.arc( centerX + Math.cos(peakAngle) * arcRadius, @@ -520,7 +533,7 @@ export class VUMeter { // Channel label const fontSize = Math.min(22, Math.max(10, h * 0.1)) - ctx.fillStyle = 'rgba(255, 255, 255, 0.45)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.9) ctx.font = `${fontSize}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'top' @@ -529,7 +542,7 @@ export class VUMeter { // dB readout const displayDb = Math.max(VU_METER_MIN_DB, Math.min(0, rmsDb)) const dbText = displayDb <= VU_METER_MIN_DB + 1 ? '-∞ dB' : `${displayDb.toFixed(1)} dB` - ctx.fillStyle = 'rgba(255, 255, 255, 0.35)' + ctx.fillStyle = alphaColor(this.options.labelColor, 0.7) ctx.font = `${Math.max(9, fontSize - 1)}px "JetBrains Mono", monospace` ctx.textAlign = 'center' ctx.textBaseline = 'bottom' diff --git a/src/renderer/visualizers/Vectorscope.ts b/src/renderer/visualizers/Vectorscope.ts index 30bf7be..be8692a 100644 --- a/src/renderer/visualizers/Vectorscope.ts +++ b/src/renderer/visualizers/Vectorscope.ts @@ -1,7 +1,7 @@ import { audioRouter } from '../audio/AudioRouter' import { vectorscope as nativeVectorscope, isNativeAvailable } from '../audio/native' import { transformPoint, drawVectorscopeGridForMode, getVectorscopeLayout } from './vectorscopeGrids' -import { MultibandSplitter, MultibandBuffer, BAND_COLORS } from './multibandSplitter' +import { MultibandSplitter, MultibandBuffer } from './multibandSplitter' import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource' import { FrameScheduler } from './frameScheduler' import { VisualizerFrameLoop } from './visualizerFrameLoop' @@ -18,6 +18,11 @@ export interface VectorscopeOptions { backgroundColor?: string showGrid?: boolean gridColor?: string + bandColors?: { + low: string + mid: string + high: string + } persistence?: number displayPoints?: number mode?: VectorscopeMode @@ -34,6 +39,11 @@ const defaultOptions: ResolvedVectorscopeOptions = { backgroundColor: 'transparent', showGrid: true, gridColor: 'rgba(255, 255, 255, 0.1)', + bandColors: { + low: '#ff4444', + mid: '#44dd44', + high: '#4488ff', + }, persistence: 0.10, displayPoints: 4096, mode: 'lissajous', @@ -370,7 +380,7 @@ export class Vectorscope { for (const band of BAND_ORDER) { const bandData = result.bands[band] - ctx.fillStyle = BAND_COLORS[band] + ctx.fillStyle = options.bandColors[band] for (let i = startIdx; i < endIdx; i++) { const point = transformPoint(bandData.left[i], bandData.right[i], mode) diff --git a/src/renderer/visualizers/Waveform.ts b/src/renderer/visualizers/Waveform.ts index 216386e..802183b 100644 --- a/src/renderer/visualizers/Waveform.ts +++ b/src/renderer/visualizers/Waveform.ts @@ -17,6 +17,13 @@ export interface WaveformDataSource extends VisualizerSessionSource { export interface WaveformOptions { lineColor?: string + gridMajorColor?: string + gridMinorColor?: string + bandColors?: { + low: string + mid: string + high: string + } scrollSpeed?: number gainDb?: number multiband?: boolean @@ -28,15 +35,18 @@ type ResolvedWaveformOptions = Required !DEFAULT_VISIBLE.includes(kind)), widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS }, @@ -167,6 +169,9 @@ export function normalizeProfile(raw: unknown, fallbackName = DEFAULT_PROFILE_NA return { name: normalizeProfileName(parsed.name, fallbackName), + themeId: typeof parsed.themeId === 'string' && parsed.themeId.trim() + ? parsed.themeId.trim() + : null, scopeOrder: normalizeScopeOrder(parsed.scopeOrder), hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), @@ -187,13 +192,21 @@ export function normalizeProfileFileScopePopouts(raw: unknown): PrismProfileFile }, {} as PrismProfileFileScopePopoutMap) } +function readProfileFileThemeId(file: Partial | PrismProfileFile): string | null { + if (!('themeId' in file)) return null + const { themeId } = file + return typeof themeId === 'string' && themeId.trim() + ? themeId.trim() + : null +} + export function normalizeProfileFile( raw: unknown, fallbackId: string, fallbackName = DEFAULT_PROFILE_NAME, -): PrismProfileFileV1 { +) : PrismProfileFileV2 { const parsed = typeof raw === 'object' && raw !== null - ? raw as Partial + ? raw as Partial : {} const id = typeof parsed.id === 'string' && parsed.id.trim() @@ -207,6 +220,7 @@ export function normalizeProfileFile( version: PROFILE_FILE_VERSION, id, name, + themeId: readProfileFileThemeId(parsed), scopeOrder: normalizeScopeOrder(parsed.scopeOrder), hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes), widthWeights: normalizeWidthWeights(parsed.widthWeights), @@ -215,7 +229,7 @@ export function normalizeProfileFile( } } -export function profileToFileData(id: string, profile: Profile): PrismProfileFileV1 { +export function profileToFileData(id: string, profile: Profile): PrismProfileFileV2 { const normalized = normalizeProfile(profile, profile.name) return { @@ -223,6 +237,7 @@ export function profileToFileData(id: string, profile: Profile): PrismProfileFil version: PROFILE_FILE_VERSION, id, name: normalized.name, + themeId: normalized.themeId, scopeOrder: [...normalized.scopeOrder], hiddenScopes: [...normalized.hiddenScopes], widthWeights: { ...normalized.widthWeights }, @@ -308,13 +323,14 @@ export function extractLocalProfileMetadata(profile: Profile): ProfileLocalMetad } export function profileFileToProfile( - file: PrismProfileFileV1, + file: PrismProfileFile, localMetadata?: ProfileLocalMetadata, ): Profile { const metadata = normalizeProfileLocalMetadata(localMetadata) return { name: normalizeProfileName(file.name, DEFAULT_PROFILE_NAME), + themeId: readProfileFileThemeId(file), scopeOrder: normalizeScopeOrder(file.scopeOrder), hiddenScopes: normalizeHiddenScopes(file.hiddenScopes), widthWeights: normalizeWidthWeights(file.widthWeights), diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts new file mode 100644 index 0000000..818eaa0 --- /dev/null +++ b/src/shared/themeState.ts @@ -0,0 +1,922 @@ +import { + DEFAULT_THEME_ID, + DEFAULT_THEME_NAME, + LEGACY_THEME_MIGRATION_VERSION, + THEME_FILE_FORMAT, + THEME_FILE_VERSION, + THEME_LOCAL_STATE_FORMAT, + THEME_LOCAL_STATE_VERSION, + type LegacyThemeMigrationPayload, + type PrismResolvedTheme, + type PrismTheme, + type PrismThemeLocalStateV1, + type ResolvedInterfaceTheme, + type ResolvedLUFSMeterTheme, + type ResolvedOscilloscopeTheme, + type ResolvedSpectrogramTheme, + type ResolvedSpectrumTheme, + type ResolvedVectorscopeTheme, + type ResolvedVUMeterTheme, + type ResolvedWaveformTheme, + type ThemeSectionName, + type ThemeTokens, +} from '../types/theme' + +const DEFAULT_BAND_LOW = '#ff4444' +const DEFAULT_BAND_MID = '#44dd44' +const DEFAULT_BAND_HIGH = '#4488ff' +const DEFAULT_WARNING = 'rgb(255, 191, 0)' +const DEFAULT_SUCCESS = '#22c55e' +const DEFAULT_DANGER = '#f87171' + +const MODULE_SECTION_ORDER: ThemeSectionName[] = [ + 'all', + 'interface', + 'spectrum', + 'oscilloscope', + 'vectorscope', + 'spectrogram', + 'vumeter', + 'lufsmeter', + 'waveform', +] + +const COLOR_KEY_ORDER: Array = [ + 'primary', + 'secondary', + 'guides', + 'text', + 'background', + 'lowBand', + 'midBand', + 'highBand', + 'fill', + 'peak', + 'clip', + 'target', + 'heatLow', + 'heatMid', + 'heatHigh', + 'success', + 'warning', + 'danger', +] + +const SECTION_KEY_MAP: Record = { + all: 'all', + interface: 'interface', + spectrum: 'spectrum', + oscilloscope: 'oscilloscope', + vectorscope: 'vectorscope', + spectrogram: 'spectrogram', + vumeter: 'vumeter', + lufsmeter: 'lufsmeter', + waveform: 'waveform', +} + +const TOKEN_KEY_MAP: Record = { + primary: 'primary', + secondary: 'secondary', + guides: 'guides', + text: 'text', + background: 'background', + low_band: 'lowBand', + mid_band: 'midBand', + high_band: 'highBand', + fill: 'fill', + peak: 'peak', + clip: 'clip', + target: 'target', + heat_low: 'heatLow', + heat_mid: 'heatMid', + heat_high: 'heatHigh', + success: 'success', + warning: 'warning', + danger: 'danger', +} + +const TOKEN_PROPERTY_KEY_MAP: Record = { + primary: 'primary', + secondary: 'secondary', + guides: 'guides', + text: 'text', + background: 'background', + lowBand: 'lowBand', + midBand: 'midBand', + highBand: 'highBand', + fill: 'fill', + peak: 'peak', + clip: 'clip', + target: 'target', + heatLow: 'heatLow', + heatMid: 'heatMid', + heatHigh: 'heatHigh', + success: 'success', + warning: 'warning', + danger: 'danger', +} + +interface RgbaColor { + r: number + g: number + b: number + a: number +} + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))) +} + +function clampAlpha(value: number): number { + return Math.max(0, Math.min(1, value)) +} + +function normalizeKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^\w]+/g, '_') + .replace(/^_+|_+$/g, '') +} + +function parseByte(token: string): number | null { + const value = Number.parseFloat(token.trim()) + if (!Number.isFinite(value)) return null + return clampByte(value) +} + +function parseThemeChannelColor(value: string): RgbaColor | null { + const parts = value.split(',').map((token) => token.trim()).filter(Boolean) + if (parts.length < 3 || parts.length > 4) return null + + const r = parseByte(parts[0]) + const g = parseByte(parts[1]) + const b = parseByte(parts[2]) + if (r === null || g === null || b === null) return null + + const a = parts.length === 4 ? parseByte(parts[3]) : 255 + if (a === null) return null + + return { + r, + g, + b, + a: clampAlpha(a / 255), + } +} + +function parseCssToken(token: string): number | null { + const trimmed = token.trim() + if (!trimmed) return null + if (trimmed.endsWith('%')) { + const percent = Number.parseFloat(trimmed.slice(0, -1)) + if (!Number.isFinite(percent)) return null + return clampByte((percent / 100) * 255) + } + const value = Number.parseFloat(trimmed) + if (!Number.isFinite(value)) return null + return clampByte(value) +} + +function parseCssAlpha(token: string): number | null { + const trimmed = token.trim() + if (!trimmed) return null + if (trimmed.endsWith('%')) { + const percent = Number.parseFloat(trimmed.slice(0, -1)) + if (!Number.isFinite(percent)) return null + return clampAlpha(percent / 100) + } + const value = Number.parseFloat(trimmed) + if (!Number.isFinite(value)) return null + return value > 1 ? clampAlpha(value / 255) : clampAlpha(value) +} + +function parseCssColor(value: string): RgbaColor | null { + const normalized = value.trim() + if (!normalized) return null + + if (normalized.startsWith('#')) { + const raw = normalized.slice(1) + const expanded = raw.length === 3 || raw.length === 4 + ? raw.split('').map((part) => `${part}${part}`).join('') + : raw + + if (expanded.length !== 6 && expanded.length !== 8) return null + + const r = Number.parseInt(expanded.slice(0, 2), 16) + const g = Number.parseInt(expanded.slice(2, 4), 16) + const b = Number.parseInt(expanded.slice(4, 6), 16) + const a = expanded.length === 8 + ? Number.parseInt(expanded.slice(6, 8), 16) / 255 + : 1 + + if ([r, g, b].some((channel) => Number.isNaN(channel))) { + return null + } + + return { r, g, b, a: clampAlpha(a) } + } + + const match = /^rgba?\((.*)\)$/i.exec(normalized) + if (!match) return null + + const body = match[1]?.trim() ?? '' + if (!body) return null + + const [colorPart, alphaPart] = body.includes('/') + ? body.split('/', 2) + : [body, undefined] + + const colorTokens = colorPart.includes(',') + ? colorPart.split(',').map((token) => token.trim()) + : colorPart.split(/\s+/).filter(Boolean) + + if (colorTokens.length < 3) return null + + const r = parseCssToken(colorTokens[0]) + const g = parseCssToken(colorTokens[1]) + const b = parseCssToken(colorTokens[2]) + if (r === null || g === null || b === null) return null + + const rawAlpha = alphaPart ?? colorTokens[3] + const a = rawAlpha ? parseCssAlpha(rawAlpha) : 1 + if (a === null) return null + + return { r, g, b, a } +} + +function toCssColor(color: RgbaColor): string { + if (Math.abs(color.a - 1) < 0.001) { + return `rgb(${color.r}, ${color.g}, ${color.b})` + } + return `rgba(${color.r}, ${color.g}, ${color.b}, ${Number(color.a.toFixed(3))})` +} + +function quantizeThemeColor(color: RgbaColor): RgbaColor { + return { + r: clampByte(color.r), + g: clampByte(color.g), + b: clampByte(color.b), + a: clampAlpha(clampByte(color.a * 255) / 255), + } +} + +function toThemeChannels(color: string): string { + const parsed = parseCssColor(color) + if (!parsed) return '0, 0, 0' + if (Math.abs(parsed.a - 1) < 0.001) { + return `${parsed.r}, ${parsed.g}, ${parsed.b}` + } + return `${parsed.r}, ${parsed.g}, ${parsed.b}, ${clampByte(parsed.a * 255)}` +} + +function withAlpha(color: string, alpha: number): string { + const parsed = parseCssColor(color) + if (!parsed) return color + return toCssColor({ ...parsed, a: clampAlpha(alpha) }) +} + +function multiplyAlpha(color: string, factor: number): string { + const parsed = parseCssColor(color) + if (!parsed) return color + return toCssColor({ ...parsed, a: clampAlpha(parsed.a * factor) }) +} + +function mixColors(left: string, right: string, amount: number): string { + const leftColor = parseCssColor(left) + const rightColor = parseCssColor(right) + if (!leftColor) return right + if (!rightColor) return left + + const t = clampAlpha(amount) + return toCssColor({ + r: clampByte(leftColor.r + (rightColor.r - leftColor.r) * t), + g: clampByte(leftColor.g + (rightColor.g - leftColor.g) * t), + b: clampByte(leftColor.b + (rightColor.b - leftColor.b) * t), + a: clampAlpha(leftColor.a + (rightColor.a - leftColor.a) * t), + }) +} + +function lighten(color: string, amount: number): string { + return mixColors(color, 'rgb(255, 255, 255)', amount) +} + +function darken(color: string, amount: number): string { + return mixColors(color, 'rgb(0, 0, 0)', amount) +} + +function colorToRgbChannels(color: string): string { + const parsed = parseCssColor(color) + if (!parsed) return '0, 0, 0' + return `${parsed.r}, ${parsed.g}, ${parsed.b}` +} + +function createEmptyThemeTokens(): ThemeTokens { + return {} +} + +function createEmptyTheme(): PrismTheme { + return { + id: DEFAULT_THEME_ID, + name: DEFAULT_THEME_NAME, + all: createEmptyThemeTokens(), + interface: createEmptyThemeTokens(), + spectrum: createEmptyThemeTokens(), + oscilloscope: createEmptyThemeTokens(), + vectorscope: createEmptyThemeTokens(), + spectrogram: createEmptyThemeTokens(), + vumeter: createEmptyThemeTokens(), + lufsmeter: createEmptyThemeTokens(), + waveform: createEmptyThemeTokens(), + } +} + +function mergeThemeTokens(base: ThemeTokens, overrides?: ThemeTokens): ThemeTokens { + return { + ...base, + ...(overrides ?? {}), + } +} + +function normalizeTokens(raw: unknown): ThemeTokens { + if (typeof raw !== 'object' || raw === null) { + return createEmptyThemeTokens() + } + + const parsed = raw as Record + const next: ThemeTokens = {} + + for (const [rawKey, rawValue] of Object.entries(parsed)) { + if (typeof rawValue !== 'string') continue + const key = TOKEN_PROPERTY_KEY_MAP[rawKey] ?? TOKEN_KEY_MAP[normalizeKey(rawKey)] + if (!key) continue + const parsedColor = parseCssColor(rawValue) ?? parseThemeChannelColor(rawValue) + if (!parsedColor) continue + next[key] = toCssColor(quantizeThemeColor(parsedColor)) + } + + return next +} + +export function createDefaultTheme(): PrismTheme { + return normalizeTheme({ + id: DEFAULT_THEME_ID, + name: DEFAULT_THEME_NAME, + credit: 'Prism', + all: { + primary: '#38bdf8', + secondary: 'rgb(172, 192, 222)', + guides: 'rgba(255, 255, 255, 0.1)', + text: 'rgb(255, 255, 255)', + background: 'rgb(0, 0, 0)', + lowBand: DEFAULT_BAND_LOW, + midBand: DEFAULT_BAND_MID, + highBand: DEFAULT_BAND_HIGH, + success: DEFAULT_SUCCESS, + warning: DEFAULT_WARNING, + danger: DEFAULT_DANGER, + }, + interface: { + secondary: 'rgba(8, 11, 16, 0.92)', + guides: 'rgba(255, 255, 255, 0.09)', + background: 'rgb(0, 0, 0)', + }, + spectrum: { + secondary: 'rgba(56, 189, 248, 0.5)', + heatLow: 'rgb(15, 7, 33)', + heatMid: 'rgb(163, 26, 121)', + heatHigh: 'rgb(255, 241, 209)', + }, + oscilloscope: { + fill: 'rgba(245, 248, 252, 0.18)', + }, + spectrogram: { + heatLow: 'rgb(15, 7, 33)', + heatMid: 'rgb(163, 26, 121)', + heatHigh: 'rgb(255, 241, 209)', + }, + vumeter: { + peak: 'rgb(255, 127, 0)', + clip: 'rgba(255, 120, 80, 0.9)', + }, + lufsmeter: { + target: 'rgba(56, 189, 248, 0.25)', + }, + }, DEFAULT_THEME_ID, DEFAULT_THEME_NAME) +} + +function cloneTheme(theme: PrismTheme): PrismTheme { + return JSON.parse(JSON.stringify(theme)) as PrismTheme +} + +function createPresetTheme(id: string, name: string, primary: string): PrismTheme { + const base = cloneTheme(createDefaultTheme()) + base.id = id + base.name = name + base.all.primary = primary + base.spectrum.secondary = multiplyAlpha(primary, 0.6) + base.lufsmeter.target = withAlpha(primary, 0.25) + return normalizeTheme(base, id, name) +} + +export function createBundledThemes(): PrismTheme[] { + return [ + createDefaultTheme(), + createPresetTheme('theme_graphite', 'Graphite', '#4fc3f7'), + createPresetTheme('theme_midnight', 'Midnight', '#4f9bff'), + createPresetTheme('theme_green', 'Green', '#4ade80'), + createPresetTheme('theme_purple', 'Purple', '#a78bfa'), + createPresetTheme('theme_rose', 'Rose', '#fb7185'), + ] +} + +export function normalizeTheme( + raw: unknown, + fallbackId = DEFAULT_THEME_ID, + fallbackName = DEFAULT_THEME_NAME, +): PrismTheme { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + + const id = typeof parsed.id === 'string' && parsed.id.trim() + ? parsed.id.trim() + : fallbackId + + const name = typeof parsed.name === 'string' && parsed.name.trim() + ? parsed.name.trim() + : fallbackName + + const normalized = createEmptyTheme() + normalized.id = id + normalized.name = name + normalized.credit = typeof parsed.credit === 'string' && parsed.credit.trim() + ? parsed.credit.trim() + : undefined + normalized.website = typeof parsed.website === 'string' && parsed.website.trim() + ? parsed.website.trim() + : undefined + normalized.description = typeof parsed.description === 'string' && parsed.description.trim() + ? parsed.description.trim() + : undefined + normalized.all = normalizeTokens(parsed.all) + normalized.interface = normalizeTokens(parsed.interface) + normalized.spectrum = normalizeTokens(parsed.spectrum) + normalized.oscilloscope = normalizeTokens(parsed.oscilloscope) + normalized.vectorscope = normalizeTokens(parsed.vectorscope) + normalized.spectrogram = normalizeTokens(parsed.spectrogram) + normalized.vumeter = normalizeTokens(parsed.vumeter) + normalized.lufsmeter = normalizeTokens(parsed.lufsmeter) + normalized.waveform = normalizeTokens(parsed.waveform) + return normalized +} + +export function createEmptyThemeLocalState(): PrismThemeLocalStateV1 { + return { + format: THEME_LOCAL_STATE_FORMAT, + version: THEME_LOCAL_STATE_VERSION, + migrationVersion: 0, + activeThemeId: null, + } +} + +export function normalizeThemeLocalState(raw: unknown): PrismThemeLocalStateV1 { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + + return { + format: THEME_LOCAL_STATE_FORMAT, + version: THEME_LOCAL_STATE_VERSION, + migrationVersion: typeof parsed.migrationVersion === 'number' && Number.isFinite(parsed.migrationVersion) + ? Math.max(0, Math.trunc(parsed.migrationVersion)) + : 0, + activeThemeId: typeof parsed.activeThemeId === 'string' + ? parsed.activeThemeId + : null, + } +} + +export function normalizeLegacyThemePayload(raw: unknown): LegacyThemeMigrationPayload { + if (typeof raw !== 'object' || raw === null) { + return { presetId: null, customAccent: null } + } + + const parsed = raw as Partial + return { + presetId: typeof parsed.presetId === 'string' ? parsed.presetId : null, + customAccent: typeof parsed.customAccent === 'string' ? parsed.customAccent : null, + } +} + +function parseThemeContent(content: string, fallbackId: string, fallbackName: string): PrismTheme { + const nextTheme = createEmptyTheme() + nextTheme.id = fallbackId + nextTheme.name = fallbackName + + let currentSection: ThemeSectionName | 'theme' | null = null + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#') || line.startsWith(';')) { + continue + } + + const sectionMatch = /^\[(.+)\]$/.exec(line) + if (sectionMatch) { + const sectionKey = normalizeKey(sectionMatch[1] ?? '') + currentSection = sectionKey === 'theme' + ? 'theme' + : (SECTION_KEY_MAP[sectionKey] ?? null) + continue + } + + const equalsIndex = line.indexOf('=') + if (equalsIndex === -1 || !currentSection) { + continue + } + + const key = normalizeKey(line.slice(0, equalsIndex)) + const value = line.slice(equalsIndex + 1).trim() + if (!value) continue + + if (currentSection === 'theme') { + switch (key) { + case 'format': + if (value !== THEME_FILE_FORMAT) { + throw new Error(`Unsupported theme format "${value}".`) + } + break + case 'version': { + const version = Number.parseInt(value, 10) + if (version !== THEME_FILE_VERSION) { + throw new Error(`Unsupported theme version "${value}".`) + } + break + } + case 'id': + nextTheme.id = value + break + case 'name': + nextTheme.name = value + break + case 'credit': + nextTheme.credit = value + break + case 'website': + nextTheme.website = value + break + case 'description': + nextTheme.description = value + break + default: + break + } + continue + } + + const tokenKey = TOKEN_KEY_MAP[key] + if (!tokenKey) continue + + const parsedColor = parseThemeChannelColor(value) + if (!parsedColor) continue + nextTheme[currentSection][tokenKey] = toCssColor(parsedColor) + } + + return normalizeTheme(nextTheme, fallbackId, fallbackName) +} + +export function parseThemeFileContent( + content: string, + fallbackId: string, + fallbackName = DEFAULT_THEME_NAME, +): PrismTheme { + return parseThemeContent(content, fallbackId, fallbackName) +} + +function serializeSection(sectionName: string, tokens: ThemeTokens): string[] { + const lines: string[] = [`[${sectionName}]`] + + for (const key of COLOR_KEY_ORDER) { + const value = tokens[key] + if (!value) continue + const serializedKey = key + .replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`) + lines.push(`${serializedKey} = ${toThemeChannels(value)}`) + } + + return lines +} + +export function serializeThemeFile(theme: PrismTheme): string { + const normalized = normalizeTheme(theme, theme.id, theme.name) + const sections: string[] = [ + '[Theme]', + `format = ${THEME_FILE_FORMAT}`, + `version = ${THEME_FILE_VERSION}`, + `id = ${normalized.id}`, + `name = ${normalized.name}`, + ] + + if (normalized.credit) sections.push(`credit = ${normalized.credit}`) + if (normalized.website) sections.push(`website = ${normalized.website}`) + if (normalized.description) sections.push(`description = ${normalized.description}`) + + const output = [sections.join('\n')] + + for (const section of MODULE_SECTION_ORDER) { + const tokens = normalized[section] + if (!Object.values(tokens).some(Boolean)) continue + const label = section === 'interface' + ? 'Interface' + : section === 'all' + ? 'All' + : section === 'vumeter' + ? 'VUMeter' + : section === 'lufsmeter' + ? 'LUFSMeter' + : `${section.charAt(0).toUpperCase()}${section.slice(1)}` + output.push(serializeSection(label, tokens).join('\n')) + } + + return `${output.join('\n\n')}\n` +} + +export function createTemplateThemeFile(): string { + return `# Prism theme template\n#\n# Authoring rules:\n# - Colors use R, G, B or R, G, B, A (0-255)\n# - Omit sections or keys you do not want to override\n# - [All] sets the defaults for everything else\n# - [Interface] overrides the app window, controls, and menus\n# - Module sections only need the colors that should differ from [All]\n\n[Theme]\nformat = ${THEME_FILE_FORMAT}\nversion = ${THEME_FILE_VERSION}\nid = theme_template\nname = Template Theme\ncredit = Your Name\nwebsite = https://example.com\n\n[All]\nprimary = 56, 189, 248\nsecondary = 172, 192, 222\nguides = 255, 255, 255, 26\ntext = 255, 255, 255\nbackground = 0, 0, 0\nlow_band = 255, 68, 68\nmid_band = 68, 221, 68\nhigh_band = 68, 136, 255\nsuccess = 34, 197, 94\nwarning = 255, 191, 0\ndanger = 248, 113, 113\n\n[Interface]\nsecondary = 8, 11, 16, 235\nguides = 255, 255, 255, 23\nbackground = 0, 0, 0\n\n[Spectrum]\nsecondary = 56, 189, 248, 127\nheat_low = 15, 7, 33\nheat_mid = 163, 26, 121\nheat_high = 255, 241, 209\n\n[Oscilloscope]\nfill = 245, 248, 252, 46\n\n[VUMeter]\npeak = 255, 127, 0\nclip = 255, 120, 80, 230\n\n[LUFSMeter]\ntarget = 56, 189, 248, 64\n` +} + +function getThemeFallbackSection(base: ThemeTokens): Required { + const primary = base.primary ?? '#38bdf8' + return { + primary, + secondary: base.secondary ?? lighten(primary, 0.22), + guides: base.guides ?? 'rgba(255, 255, 255, 0.1)', + text: base.text ?? 'rgb(255, 255, 255)', + background: base.background ?? 'transparent', + lowBand: base.lowBand ?? DEFAULT_BAND_LOW, + midBand: base.midBand ?? DEFAULT_BAND_MID, + highBand: base.highBand ?? DEFAULT_BAND_HIGH, + fill: base.fill ?? withAlpha(primary, 0.18), + peak: base.peak ?? 'rgb(255, 127, 0)', + clip: base.clip ?? 'rgba(255, 120, 80, 0.9)', + target: base.target ?? withAlpha(primary, 0.25), + heatLow: base.heatLow ?? 'rgb(15, 7, 33)', + heatMid: base.heatMid ?? 'rgb(163, 26, 121)', + heatHigh: base.heatHigh ?? 'rgb(255, 241, 209)', + success: base.success ?? DEFAULT_SUCCESS, + warning: base.warning ?? DEFAULT_WARNING, + danger: base.danger ?? DEFAULT_DANGER, + } +} + +function resolveInterfaceTheme(theme: PrismTheme, all: Required): ResolvedInterfaceTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.interface)) + const background = section.background + const surface = theme.interface.secondary ?? mixColors(background, section.text, 0.06) + const guides = theme.interface.guides ?? all.guides + const primary = section.primary + const text = section.text + + return { + primary, + secondary: section.secondary, + guides, + text, + background, + accent: primary, + accentHover: lighten(primary, 0.2), + accentGlow: withAlpha(primary, 0.3), + accentRgb: colorToRgbChannels(primary), + bgPrimary: background, + bgSecondary: darken(background, 0.04), + bgTertiary: darken(background, 0.08), + panelSurface: surface, + panelSurfaceSoft: multiplyAlpha(surface, 0.92), + panelOutline: withAlpha(guides, 0.5), + panelOutlineStrong: withAlpha(guides, 0.9), + glassBg: withAlpha(surface, 0.18), + glassBorder: withAlpha(guides, 0.7), + glassHighlight: withAlpha(text, 0.05), + textPrimary: text, + textSecondary: withAlpha(text, 0.62), + textTertiary: withAlpha(text, 0.42), + textMuted: withAlpha(text, 0.3), + toolbarBg: withAlpha(background, 0.74), + settingsBgTop: multiplyAlpha(surface, 0.98), + settingsBgBottom: withAlpha(darken(background, 0.2), 0.98), + bottomBarBg: withAlpha(darken(background, 0.08), 0.98), + menuBg: withAlpha(surface, 0.96), + menuBorder: withAlpha(guides, 0.75), + controlBg: withAlpha(section.secondary, 0.08), + controlBgHover: withAlpha(lighten(section.secondary, 0.08), 0.12), + controlBgActive: withAlpha(primary, 0.12), + controlBorder: withAlpha(guides, 0.7), + controlBorderActive: withAlpha(primary, 0.34), + inputBg: withAlpha(surface, 0.98), + inputBgFocus: withAlpha(lighten(surface, 0.05), 0.98), + inputBorder: withAlpha(guides, 0.8), + inputBorderFocus: withAlpha(primary, 0.7), + divider: withAlpha(guides, 0.55), + success: all.success, + warning: all.warning, + danger: all.danger, + } +} + +function resolveSpectrumTheme(theme: PrismTheme, all: Required): ResolvedSpectrumTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.spectrum)) + return { + primary: section.primary, + secondary: section.secondary, + guides: section.guides, + background: theme.spectrum.background ?? 'transparent', + fillGradient: [ + withAlpha(section.primary, 0), + withAlpha(section.primary, 0.3), + withAlpha(section.secondary, 0.5), + ], + heatColors: [section.heatLow, section.heatMid, section.heatHigh], + } +} + +function resolveOscilloscopeTheme(theme: PrismTheme, all: Required): ResolvedOscilloscopeTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.oscilloscope)) + return { + primary: section.primary, + guides: section.guides, + background: theme.oscilloscope.background ?? 'transparent', + fill: section.fill, + } +} + +function resolveVectorscopeTheme(theme: PrismTheme, all: Required): ResolvedVectorscopeTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.vectorscope)) + return { + primary: section.primary, + guides: section.guides, + background: theme.vectorscope.background ?? 'transparent', + lowBand: section.lowBand, + midBand: section.midBand, + highBand: section.highBand, + } +} + +function resolveSpectrogramTheme(theme: PrismTheme, all: Required): ResolvedSpectrogramTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.spectrogram)) + return { + primary: section.primary, + guides: section.guides, + background: theme.spectrogram.background ?? 'transparent', + heatColors: [section.heatLow, section.heatMid, section.heatHigh], + } +} + +function resolveVUMeterTheme(theme: PrismTheme, all: Required): ResolvedVUMeterTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.vumeter)) + return { + primary: section.primary, + peak: section.peak, + clip: section.clip, + guides: section.guides, + text: section.text, + background: theme.vumeter.background ?? 'transparent', + } +} + +function resolveLUFSMeterTheme(theme: PrismTheme, all: Required): ResolvedLUFSMeterTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.lufsmeter)) + return { + primary: section.primary, + target: section.target, + guides: section.guides, + text: section.text, + background: theme.lufsmeter.background ?? 'transparent', + } +} + +function resolveWaveformTheme(theme: PrismTheme, all: Required): ResolvedWaveformTheme { + const section = getThemeFallbackSection(mergeThemeTokens(all, theme.waveform)) + return { + primary: section.primary, + guides: section.guides, + background: theme.waveform.background ?? 'transparent', + lowBand: section.lowBand, + midBand: section.midBand, + highBand: section.highBand, + } +} + +export function resolveTheme(theme: PrismTheme): PrismResolvedTheme { + const normalized = normalizeTheme(theme, theme.id, theme.name) + const baseAll = getThemeFallbackSection(mergeThemeTokens(createDefaultTheme().all, normalized.all)) + + return { + id: normalized.id, + name: normalized.name, + credit: normalized.credit, + website: normalized.website, + description: normalized.description, + interface: resolveInterfaceTheme(normalized, baseAll), + spectrum: resolveSpectrumTheme(normalized, baseAll), + oscilloscope: resolveOscilloscopeTheme(normalized, baseAll), + vectorscope: resolveVectorscopeTheme(normalized, baseAll), + spectrogram: resolveSpectrogramTheme(normalized, baseAll), + vumeter: resolveVUMeterTheme(normalized, baseAll), + lufsmeter: resolveLUFSMeterTheme(normalized, baseAll), + waveform: resolveWaveformTheme(normalized, baseAll), + } +} + +export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null { + switch (payload.presetId) { + case 'default': + return DEFAULT_THEME_ID + case 'graphite': + return 'theme_graphite' + case 'midnight': + return 'theme_midnight' + case 'green': + return 'theme_green' + case 'purple': + return 'theme_purple' + case 'rose': + return 'theme_rose' + default: + return null + } +} + +export function createMigratedAccentTheme(accent: string): PrismTheme | null { + const parsed = parseCssColor(accent) + if (!parsed) return null + + const base = cloneTheme(createDefaultTheme()) + base.id = 'theme_migrated_accent' + base.name = 'Migrated Accent' + base.all.primary = toCssColor(parsed) + base.spectrum.secondary = withAlpha(base.all.primary, 0.5) + base.lufsmeter.target = withAlpha(base.all.primary, 0.25) + return normalizeTheme(base, base.id, base.name) +} + +export function themeToCssVariables(theme: Pick): Record { + const ui = theme.interface + return { + '--bg-primary': ui.bgPrimary, + '--bg-secondary': ui.bgSecondary, + '--bg-tertiary': ui.bgTertiary, + '--panel-surface': ui.panelSurface, + '--panel-surface-soft': ui.panelSurfaceSoft, + '--panel-outline': ui.panelOutline, + '--panel-outline-strong': ui.panelOutlineStrong, + '--glass-bg': ui.glassBg, + '--glass-border': ui.glassBorder, + '--glass-highlight': ui.glassHighlight, + '--text-primary': ui.textPrimary, + '--text-secondary': ui.textSecondary, + '--text-tertiary': ui.textTertiary, + '--text-muted': ui.textMuted, + '--danger': ui.danger, + '--warning': ui.warning, + '--success': ui.success, + '--accent': ui.accent, + '--accent-hover': ui.accentHover, + '--accent-glow': ui.accentGlow, + '--accent-rgb': ui.accentRgb, + '--toolbar-bg': ui.toolbarBg, + '--settings-bg-top': ui.settingsBgTop, + '--settings-bg-bottom': ui.settingsBgBottom, + '--bottom-bar-bg': ui.bottomBarBg, + '--menu-bg': ui.menuBg, + '--menu-border': ui.menuBorder, + '--control-bg': ui.controlBg, + '--control-bg-hover': ui.controlBgHover, + '--control-bg-active': ui.controlBgActive, + '--control-border': ui.controlBorder, + '--control-border-active': ui.controlBorderActive, + '--input-bg': ui.inputBg, + '--input-bg-focus': ui.inputBgFocus, + '--input-border': ui.inputBorder, + '--input-border-focus': ui.inputBorderFocus, + '--divider': ui.divider, + } +} + +export function applyResolvedThemeToDocument( + theme: Pick, + root: Pick, +): void { + const variables = themeToCssVariables(theme) + for (const [name, value] of Object.entries(variables)) { + root.setProperty(name, value) + } +} + +export function getDefaultThemeIdForLocalState(): string { + return DEFAULT_THEME_ID +} + +export function getLegacyThemeMigrationVersion(): number { + return LEGACY_THEME_MIGRATION_VERSION +} diff --git a/src/types/popout.ts b/src/types/popout.ts index 1eeacf4..9814231 100644 --- a/src/types/popout.ts +++ b/src/types/popout.ts @@ -1,6 +1,16 @@ import type { CaptureBackendKind } from './capture' import type { ScopeKind } from './scope' import type { ScopeSettings } from './settings' +import type { + ResolvedInterfaceTheme, + ResolvedLUFSMeterTheme, + ResolvedOscilloscopeTheme, + ResolvedSpectrogramTheme, + ResolvedSpectrumTheme, + ResolvedVectorscopeTheme, + ResolvedVUMeterTheme, + ResolvedWaveformTheme, +} from './theme' export interface WindowBounds { x: number @@ -40,9 +50,19 @@ export type ScopePopoutMonoBatch = Float32Array[] export type ScopePopoutStereoBatch = ScopePopoutStereoChunk[] export type ScopePopoutAudioBatch = ScopePopoutMonoBatch | ScopePopoutStereoBatch +export type ScopePopoutResolvedScopeTheme = + | ResolvedSpectrumTheme + | ResolvedOscilloscopeTheme + | ResolvedVectorscopeTheme + | ResolvedSpectrogramTheme + | ResolvedVUMeterTheme + | ResolvedLUFSMeterTheme + | ResolvedWaveformTheme + export interface ScopePopoutSnapshot { kind: K label: string - accent: string + interfaceTheme: ResolvedInterfaceTheme + scopeTheme: ScopePopoutResolvedScopeTheme settings: ScopeSettings[K] } diff --git a/src/types/profile.ts b/src/types/profile.ts index 9f59a9a..0bbba51 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -3,7 +3,7 @@ import type { ScopeKind } from './scope' import type { ScopeSettings } from './settings' export const PROFILE_FILE_FORMAT = 'prism-profile' -export const PROFILE_FILE_VERSION = 1 +export const PROFILE_FILE_VERSION = 2 export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local' export const PROFILE_LOCAL_STATE_VERSION = 1 export const LEGACY_PROFILE_MIGRATION_VERSION = 1 @@ -12,6 +12,7 @@ export const DEFAULT_PROFILE_NAME = 'Default' export interface Profile { name: string + themeId: string | null scopeOrder: ScopeKind[] hiddenScopes: ScopeKind[] widthWeights: Record @@ -28,7 +29,7 @@ export type PrismProfileFileScopePopoutMap = Record + scopeSettings: ScopeSettings + scopePopouts: PrismProfileFileScopePopoutMap +} + +export type PrismProfileFile = PrismProfileFileV1 | PrismProfileFileV2 + export interface ProfileLocalMetadata { windowBounds?: WindowBounds scopePopoutBounds?: Partial> diff --git a/src/types/theme.ts b/src/types/theme.ts new file mode 100644 index 0000000..1db887f --- /dev/null +++ b/src/types/theme.ts @@ -0,0 +1,199 @@ +import type { ScopeKind } from './scope' + +export const THEME_FILE_FORMAT = 'prism-theme' +export const THEME_FILE_VERSION = 1 +export const THEME_LOCAL_STATE_FORMAT = 'prism-theme-local' +export const THEME_LOCAL_STATE_VERSION = 1 +export const LEGACY_THEME_MIGRATION_VERSION = 1 +export const DEFAULT_THEME_ID = 'theme_default' +export const DEFAULT_THEME_NAME = 'Default' + +export type ThemeSectionName = + | 'all' + | 'interface' + | ScopeKind + +export interface ThemeTokens { + primary?: string + secondary?: string + guides?: string + text?: string + background?: string + lowBand?: string + midBand?: string + highBand?: string + fill?: string + peak?: string + clip?: string + target?: string + heatLow?: string + heatMid?: string + heatHigh?: string + success?: string + warning?: string + danger?: string +} + +export interface PrismTheme { + id: string + name: string + credit?: string + website?: string + description?: string + all: ThemeTokens + interface: ThemeTokens + spectrum: ThemeTokens + oscilloscope: ThemeTokens + vectorscope: ThemeTokens + spectrogram: ThemeTokens + vumeter: ThemeTokens + lufsmeter: ThemeTokens + waveform: ThemeTokens +} + +export interface PrismThemeLocalStateV1 { + format: typeof THEME_LOCAL_STATE_FORMAT + version: typeof THEME_LOCAL_STATE_VERSION + migrationVersion: number + activeThemeId: string | null +} + +export interface ThemeSummary { + id: string + name: string + isDefault: boolean +} + +export interface ThemeLibrarySnapshot { + themes: Record + activeThemeId: string | null +} + +export interface LegacyThemeMigrationPayload { + presetId: string | null + customAccent: string | null +} + +export interface LegacyThemeMigrationResult { + didMigrate: boolean + snapshot: ThemeLibrarySnapshot +} + +export interface ResolvedInterfaceTheme { + primary: string + secondary: string + guides: string + text: string + background: string + accent: string + accentHover: string + accentGlow: string + accentRgb: string + bgPrimary: string + bgSecondary: string + bgTertiary: string + panelSurface: string + panelSurfaceSoft: string + panelOutline: string + panelOutlineStrong: string + glassBg: string + glassBorder: string + glassHighlight: string + textPrimary: string + textSecondary: string + textTertiary: string + textMuted: string + toolbarBg: string + settingsBgTop: string + settingsBgBottom: string + bottomBarBg: string + menuBg: string + menuBorder: string + controlBg: string + controlBgHover: string + controlBgActive: string + controlBorder: string + controlBorderActive: string + inputBg: string + inputBgFocus: string + inputBorder: string + inputBorderFocus: string + divider: string + success: string + warning: string + danger: string +} + +export interface ResolvedSpectrumTheme { + primary: string + secondary: string + guides: string + background: string + fillGradient: [string, string, string] + heatColors: [string, string, string] +} + +export interface ResolvedOscilloscopeTheme { + primary: string + guides: string + background: string + fill: string +} + +export interface ResolvedVectorscopeTheme { + primary: string + guides: string + background: string + lowBand: string + midBand: string + highBand: string +} + +export interface ResolvedSpectrogramTheme { + primary: string + guides: string + background: string + heatColors: [string, string, string] +} + +export interface ResolvedVUMeterTheme { + primary: string + peak: string + clip: string + guides: string + text: string + background: string +} + +export interface ResolvedLUFSMeterTheme { + primary: string + target: string + guides: string + text: string + background: string +} + +export interface ResolvedWaveformTheme { + primary: string + guides: string + background: string + lowBand: string + midBand: string + highBand: string +} + +export interface PrismResolvedTheme { + id: string + name: string + credit?: string + website?: string + description?: string + interface: ResolvedInterfaceTheme + spectrum: ResolvedSpectrumTheme + oscilloscope: ResolvedOscilloscopeTheme + vectorscope: ResolvedVectorscopeTheme + spectrogram: ResolvedSpectrogramTheme + vumeter: ResolvedVUMeterTheme + lufsmeter: ResolvedLUFSMeterTheme + waveform: ResolvedWaveformTheme +} diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index e2dee73..317901e 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -24,6 +24,18 @@ async function createHarness(): Promise<{ localStatePath: string profilesDir: string rootDir: string +}> { + return createHarnessWithOptions() +} + +async function createHarnessWithOptions(options?: { + defaultThemeId?: string | null +}): Promise<{ + cleanup: () => Promise + library: FileBackedProfileLibrary + localStatePath: string + profilesDir: string + rootDir: string }> { const rootDir = await mkdtemp(join(tmpdir(), 'prism-profile-library-')) const profilesDir = join(rootDir, 'Documents', 'Prism Profiles') @@ -31,7 +43,13 @@ async function createHarness(): Promise<{ return { cleanup: () => rm(rootDir, { recursive: true, force: true }), - library: new FileBackedProfileLibrary(profilesDir, localStatePath), + library: new FileBackedProfileLibrary( + profilesDir, + localStatePath, + options && 'defaultThemeId' in options + ? async () => options.defaultThemeId ?? null + : undefined, + ), localStatePath, profilesDir, rootDir, @@ -40,6 +58,7 @@ async function createHarness(): Promise<{ function createProfile(name: string): Profile { const profile = createDefaultProfile(name) + profile.themeId = 'theme_default' profile.scopePopouts.spectrum = { poppedOut: true, windowBounds: { x: 120, y: 40, width: 420, height: 240 }, @@ -55,6 +74,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.format, PROFILE_FILE_FORMAT) assert.equal(file.version, PROFILE_FILE_VERSION) + assert.equal(file.themeId, 'theme_default') assert.equal(JSON.stringify(file).includes('windowBounds'), false) assert.equal(JSON.stringify(file).includes('frameTarget'), false) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) @@ -99,6 +119,24 @@ test('library saves, renames, deletes, and resolves filename collisions', async } }) +test('default profile seeds with the current active theme when available', async () => { + const harness = await createHarnessWithOptions({ defaultThemeId: 'theme_midnight' }) + + try { + const snapshot = await harness.library.getSnapshot() + assert.equal(snapshot.profiles[DEFAULT_PROFILE_ID]?.themeId, 'theme_midnight') + + const defaultFile = JSON.parse( + await readFile(join(harness.profilesDir, 'Default.prsm'), 'utf8'), + ) as { + themeId?: string | null + } + assert.equal(defaultFile.themeId, 'theme_midnight') + } finally { + await harness.cleanup() + } +}) + test('importing the same embedded id replaces the managed profile instead of duplicating it', async () => { const harness = await createHarness() @@ -148,7 +186,7 @@ test('partial files normalize, unsupported versions fail, and import does not ch const partialPath = join(harness.rootDir, 'partial.prsm') await writeFile(partialPath, `${JSON.stringify({ format: PROFILE_FILE_FORMAT, - version: PROFILE_FILE_VERSION, + version: 1, id: 'profile_partial', name: 'Partial', scopeOrder: ['spectrogram'], diff --git a/test/theme-library.test.ts b/test/theme-library.test.ts new file mode 100644 index 0000000..2a77246 --- /dev/null +++ b/test/theme-library.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { FileBackedThemeLibrary } from '../src/main/themeLibrary' +import { + createDefaultTheme, + parseThemeFileContent, + serializeThemeFile, +} from '../src/shared/themeState' +import { + DEFAULT_THEME_ID, + DEFAULT_THEME_NAME, +} from '../src/types/theme' + +async function createHarness(): Promise<{ + cleanup: () => Promise + library: FileBackedThemeLibrary + localStatePath: string + themesDir: string + rootDir: string +}> { + const rootDir = await mkdtemp(join(tmpdir(), 'prism-theme-library-')) + const themesDir = join(rootDir, 'Documents', 'Prism Themes') + const localStatePath = join(rootDir, 'userData', 'theme-state.json') + + return { + cleanup: () => rm(rootDir, { recursive: true, force: true }), + library: new FileBackedThemeLibrary(themesDir, localStatePath), + localStatePath, + themesDir, + rootDir, + } +} + +test('theme files round-trip and keep grouped sections intact', () => { + const theme = createDefaultTheme() + theme.spectrum.heatMid = 'rgb(200, 50, 120)' + + const serialized = serializeThemeFile(theme) + const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_ID, DEFAULT_THEME_NAME) + + assert.equal(parsed.id, DEFAULT_THEME_ID) + assert.equal(parsed.name, DEFAULT_THEME_NAME) + assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)') + assert.equal(parsed.interface.secondary, theme.interface.secondary) +}) + +test('library seeds default themes and template file', async () => { + const harness = await createHarness() + + try { + const snapshot = await harness.library.getSnapshot() + assert.ok(snapshot.themes[DEFAULT_THEME_ID]) + assert.equal(snapshot.activeThemeId, DEFAULT_THEME_ID) + + const fileNames = (await readdir(harness.themesDir)).sort() + assert.ok(fileNames.includes('Default.iro')) + assert.ok(fileNames.includes('_TEMPLATE.iro')) + } finally { + await harness.cleanup() + } +}) + +test('importing the same embedded theme id replaces the managed theme', async () => { + const harness = await createHarness() + + try { + const theme = createDefaultTheme() + theme.id = 'theme_shared' + theme.name = 'Shared' + const externalPath = join(harness.rootDir, 'shared.iro') + await writeFile(externalPath, serializeThemeFile(theme), 'utf8') + + const firstSnapshot = await harness.library.importThemeFromPath(externalPath) + assert.equal(firstSnapshot.activeThemeId, 'theme_shared') + + theme.name = 'Shared Updated' + theme.all.primary = 'rgb(74, 222, 128)' + const updatedPath = join(harness.rootDir, 'shared-updated.iro') + await writeFile(updatedPath, serializeThemeFile(theme), 'utf8') + + const secondSnapshot = await harness.library.importThemeFromPath(updatedPath) + assert.equal(secondSnapshot.activeThemeId, 'theme_shared') + assert.equal(secondSnapshot.themes.theme_shared.name, 'Shared Updated') + assert.equal(secondSnapshot.themes.theme_shared.all.primary, 'rgb(74, 222, 128)') + } finally { + await harness.cleanup() + } +}) + +test('legacy migration can create an accent theme and make it active', async () => { + const harness = await createHarness() + + try { + const migration = await harness.library.migrateLegacyTheme({ + presetId: 'default', + customAccent: '#4ade80', + }) + + assert.equal(migration.didMigrate, true) + assert.equal(migration.snapshot.activeThemeId, 'theme_migrated_accent') + assert.ok(migration.snapshot.themes.theme_migrated_accent) + + const localState = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as { + activeThemeId: string | null + migrationVersion: number + } + assert.equal(localState.activeThemeId, 'theme_migrated_accent') + assert.equal(localState.migrationVersion, 1) + } finally { + await harness.cleanup() + } +})