mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
update theming system, minor qol improvements
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"test:astra": "node scripts/run-astra-integration-tests.mjs",
|
||||
"test:profiles": "node scripts/run-profile-library-tests.mjs",
|
||||
"test:themes": "node scripts/run-theme-library-tests.mjs",
|
||||
"test:window-state": "node scripts/run-window-state-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'})\"",
|
||||
|
||||
@@ -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-window-state-tests-'))
|
||||
const bundledTestPath = join(tempDir, 'window-state-store.test.mjs')
|
||||
const entryPoint = join(rootDir, 'test', 'window-state-store.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)
|
||||
+119
-68
@@ -27,6 +27,7 @@ import { calculateResizedWindowBounds } from '../shared/windowResize'
|
||||
import { FileBackedProfileLibrary } from './profileLibrary'
|
||||
import { AstraIntegrationService } from './services/astraIntegration'
|
||||
import { FileBackedThemeLibrary } from './themeLibrary'
|
||||
import { FileBackedWindowStateStore } from './windowStateStore'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let moveInterval: ReturnType<typeof setInterval> | null = null
|
||||
@@ -55,6 +56,7 @@ const pendingThemeOpenPaths: string[] = []
|
||||
let profileLibrary: FileBackedProfileLibrary | null = null
|
||||
let themeLibrary: FileBackedThemeLibrary | null = null
|
||||
let astraIntegrationService: AstraIntegrationService | null = null
|
||||
let windowStateStore: FileBackedWindowStateStore | null = null
|
||||
|
||||
const WINDOW_DEFAULTS = {
|
||||
width: 900,
|
||||
@@ -93,6 +95,16 @@ function getThemeLibrary(): FileBackedThemeLibrary {
|
||||
return themeLibrary
|
||||
}
|
||||
|
||||
function getWindowStateStore(): FileBackedWindowStateStore {
|
||||
if (!windowStateStore) {
|
||||
windowStateStore = new FileBackedWindowStateStore(
|
||||
join(app.getPath('userData'), 'window-state.json'),
|
||||
)
|
||||
}
|
||||
|
||||
return windowStateStore
|
||||
}
|
||||
|
||||
function broadcastAstraState(state: AstraIntegrationState): void {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed() || window.webContents.isDestroyed()) continue
|
||||
@@ -242,6 +254,27 @@ function isMainRendererWindow(window: BrowserWindow | null): boolean {
|
||||
return window !== null && window === mainWindow
|
||||
}
|
||||
|
||||
function getScopeKindForWindow(window: BrowserWindow | null): ScopeKind | null {
|
||||
if (!window) return null
|
||||
|
||||
for (const [kind, popoutWindow] of scopePopoutWindows) {
|
||||
if (popoutWindow === window) {
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function describeWindow(window: BrowserWindow): string {
|
||||
if (isMainRendererWindow(window)) {
|
||||
return 'main window'
|
||||
}
|
||||
|
||||
const kind = getScopeKindForWindow(window)
|
||||
return kind ? `${kind} popout` : `window ${window.id}`
|
||||
}
|
||||
|
||||
function getBaseMinHeight(window: BrowserWindow): number {
|
||||
return isMainRendererWindow(window) ? WINDOW_DEFAULTS.minHeight : POPOUT_DEFAULTS.minHeight
|
||||
}
|
||||
@@ -496,6 +529,84 @@ function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
|
||||
}
|
||||
}
|
||||
|
||||
function emitAlwaysOnTopChanged(window: BrowserWindow, next: boolean): void {
|
||||
if (window.isDestroyed() || window.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
window.webContents.send('window:always-on-top-changed', next)
|
||||
}
|
||||
|
||||
async function persistAlwaysOnTopPreference(window: BrowserWindow, next: boolean): Promise<void> {
|
||||
if (isMainRendererWindow(window)) {
|
||||
await getWindowStateStore().setMainAlwaysOnTop(next)
|
||||
return
|
||||
}
|
||||
|
||||
const kind = getScopeKindForWindow(window)
|
||||
if (kind) {
|
||||
await getWindowStateStore().setPopoutAlwaysOnTop(kind, next)
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowAlwaysOnTop(window: BrowserWindow, next: boolean): void {
|
||||
if (window.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
window.setAlwaysOnTop(next)
|
||||
emitAlwaysOnTopChanged(window, next)
|
||||
void persistAlwaysOnTopPreference(window, next).catch((error) => {
|
||||
console.warn(`Could not persist always-on-top state for ${describeWindow(window)}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
function attachWindowShortcuts(window: BrowserWindow): void {
|
||||
const scopeKeys = new Map([
|
||||
['1', 0],
|
||||
['2', 1],
|
||||
['3', 2],
|
||||
['4', 3],
|
||||
['5', 4],
|
||||
['6', 5],
|
||||
['7', 6],
|
||||
['8', 7],
|
||||
])
|
||||
|
||||
window.webContents.on('before-input-event', (_event, input: Electron.Input) => {
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
|
||||
const isMainWindow = isMainRendererWindow(window)
|
||||
|
||||
if (!input.alt && !input.control && !input.meta && !input.shift) {
|
||||
if (isMainWindow) {
|
||||
const scopeIndex = scopeKeys.get(input.key)
|
||||
if (scopeIndex !== undefined) {
|
||||
window.webContents.send('shortcut:toggle-scope', scopeIndex)
|
||||
return
|
||||
}
|
||||
|
||||
if (input.key === ' ') {
|
||||
window.webContents.send('shortcut:toggle-capture')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (input.key === 't') {
|
||||
setWindowAlwaysOnTop(window, !window.isAlwaysOnTop())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isMainWindow && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) {
|
||||
window.webContents.send('shortcut:toggle-settings')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loadRendererTarget(window: BrowserWindow, query: Record<string, string>): void {
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
const url = new URL(process.env.ELECTRON_RENDERER_URL)
|
||||
@@ -513,7 +624,7 @@ function createMainWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
...WINDOW_DEFAULTS,
|
||||
...getFramelessWindowChromeOptions(),
|
||||
alwaysOnTop: true,
|
||||
alwaysOnTop: getWindowStateStore().getMainAlwaysOnTop(),
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
maximizable: true,
|
||||
@@ -576,16 +687,10 @@ function createMainWindow(): void {
|
||||
scheduleMainWindowBoundsSave(mainWindow)
|
||||
})
|
||||
|
||||
attachWindowShortcuts(mainWindow)
|
||||
loadRendererTarget(mainWindow, { window: 'main' })
|
||||
}
|
||||
|
||||
function setAllWindowsAlwaysOnTop(next: boolean): void {
|
||||
mainWindow?.setAlwaysOnTop(next)
|
||||
for (const window of scopePopoutWindows.values()) {
|
||||
window.setAlwaysOnTop(next)
|
||||
}
|
||||
}
|
||||
|
||||
function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed() || !mainRendererReady) return
|
||||
|
||||
@@ -664,7 +769,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
||||
skipTaskbar: true,
|
||||
autoHideMenuBar: true,
|
||||
title: `Prism ${kind}`,
|
||||
alwaysOnTop: mainWindow.isAlwaysOnTop(),
|
||||
alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind),
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -713,6 +818,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
||||
popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow))
|
||||
popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow))
|
||||
|
||||
attachWindowShortcuts(popoutWindow)
|
||||
loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind })
|
||||
return popoutWindow
|
||||
}
|
||||
@@ -888,19 +994,11 @@ function setupIPC(): void {
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow) return
|
||||
|
||||
const current = targetWindow.isAlwaysOnTop()
|
||||
const next = !current
|
||||
if (mainWindow && targetWindow === mainWindow) {
|
||||
setAllWindowsAlwaysOnTop(next)
|
||||
mainWindow?.webContents.send('window:always-on-top-changed', next)
|
||||
return
|
||||
}
|
||||
|
||||
targetWindow.setAlwaysOnTop(next)
|
||||
setWindowAlwaysOnTop(targetWindow, !targetWindow.isAlwaysOnTop())
|
||||
})
|
||||
|
||||
ipcMain.handle('window:is-always-on-top', (event) => {
|
||||
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true
|
||||
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false
|
||||
})
|
||||
|
||||
ipcMain.handle('audio:get-desktop-sources', async () => {
|
||||
@@ -1216,64 +1314,17 @@ function setupIPC(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function setupShortcuts(): void {
|
||||
if (!mainWindow) return
|
||||
|
||||
const shortcutWindow = mainWindow
|
||||
const scopeKeys = new Map([
|
||||
['1', 0],
|
||||
['2', 1],
|
||||
['3', 2],
|
||||
['4', 3],
|
||||
['5', 4],
|
||||
['6', 5],
|
||||
['7', 6],
|
||||
['8', 7],
|
||||
])
|
||||
const beforeInputHandler = (_event: Electron.Event, input: Electron.Input) => {
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!input.alt && !input.control && !input.meta && !input.shift) {
|
||||
const scopeIndex = scopeKeys.get(input.key)
|
||||
if (scopeIndex !== undefined) {
|
||||
shortcutWindow.webContents.send('shortcut:toggle-scope', scopeIndex)
|
||||
return
|
||||
}
|
||||
|
||||
if (input.key === 't') {
|
||||
const next = !shortcutWindow.isAlwaysOnTop()
|
||||
setAllWindowsAlwaysOnTop(next)
|
||||
shortcutWindow.webContents.send('window:always-on-top-changed', next)
|
||||
return
|
||||
}
|
||||
|
||||
if (input.key === ' ') {
|
||||
shortcutWindow.webContents.send('shortcut:toggle-capture')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) {
|
||||
shortcutWindow.webContents.send('shortcut:toggle-settings')
|
||||
}
|
||||
}
|
||||
|
||||
shortcutWindow.webContents.on('before-input-event', beforeInputHandler)
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
setupPermissions()
|
||||
void getAstraIntegrationService().initialize()
|
||||
setupIPC()
|
||||
await getWindowStateStore().initialize()
|
||||
createMainWindow()
|
||||
setupShortcuts()
|
||||
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
|
||||
queueThemeOpenPaths(extractThemePathsFromArgv(process.argv))
|
||||
void processPendingProfileOpenPaths()
|
||||
|
||||
@@ -167,10 +167,7 @@ export class FileBackedThemeLibrary {
|
||||
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()
|
||||
}
|
||||
entries = await this.syncBundledThemes(entries)
|
||||
|
||||
await this.ensureTemplateFile()
|
||||
|
||||
@@ -188,6 +185,26 @@ export class FileBackedThemeLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncBundledThemes(entries: ManagedThemeEntry[]): Promise<ManagedThemeEntry[]> {
|
||||
let nextEntries = entries
|
||||
|
||||
for (const theme of createBundledThemes()) {
|
||||
const existingEntry = nextEntries.find((entry) => entry.id === theme.id) ?? null
|
||||
const shouldWrite = !existingEntry || serializeThemeFile(existingEntry.theme) !== serializeThemeFile(theme)
|
||||
if (!shouldWrite) continue
|
||||
|
||||
await this.writeManagedTheme(nextEntries, theme.id, theme, existingEntry?.path)
|
||||
nextEntries = await this.readManagedEntries()
|
||||
}
|
||||
|
||||
if (!nextEntries.some((entry) => entry.id === DEFAULT_THEME_ID)) {
|
||||
await this.writeManagedTheme(nextEntries, DEFAULT_THEME_ID, createDefaultTheme())
|
||||
nextEntries = await this.readManagedEntries()
|
||||
}
|
||||
|
||||
return nextEntries
|
||||
}
|
||||
|
||||
private async ensureTemplateFile(): Promise<void> {
|
||||
const targetPath = resolve(join(this.themesDir, TEMPLATE_THEME_FILE_NAME))
|
||||
if (await this.pathExists(targetPath)) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import type { PrismWindowLocalStateV1 } from '../types/windowState'
|
||||
|
||||
export class FileBackedWindowStateStore {
|
||||
private state: PrismWindowLocalStateV1 = createEmptyWindowLocalState()
|
||||
private initialized = false
|
||||
private writeChain: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(private readonly localStatePath: string) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
this.state = await this.readLocalState()
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
getMainAlwaysOnTop(): boolean {
|
||||
return this.state.mainAlwaysOnTop
|
||||
}
|
||||
|
||||
getPopoutAlwaysOnTop(kind: ScopeKind): boolean {
|
||||
return this.state.popoutAlwaysOnTop[kind] === true
|
||||
}
|
||||
|
||||
async setMainAlwaysOnTop(alwaysOnTop: boolean): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
this.state = normalizeWindowLocalState({
|
||||
...this.state,
|
||||
mainAlwaysOnTop: alwaysOnTop,
|
||||
popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop },
|
||||
})
|
||||
await this.persistState()
|
||||
}
|
||||
|
||||
async setPopoutAlwaysOnTop(kind: ScopeKind, alwaysOnTop: boolean): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
|
||||
const popoutAlwaysOnTop = { ...this.state.popoutAlwaysOnTop }
|
||||
if (alwaysOnTop) {
|
||||
popoutAlwaysOnTop[kind] = true
|
||||
} else {
|
||||
delete popoutAlwaysOnTop[kind]
|
||||
}
|
||||
|
||||
this.state = normalizeWindowLocalState({
|
||||
...this.state,
|
||||
popoutAlwaysOnTop,
|
||||
})
|
||||
await this.persistState()
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
private async readLocalState(): Promise<PrismWindowLocalStateV1> {
|
||||
try {
|
||||
const raw = await readFile(this.localStatePath, 'utf8')
|
||||
return normalizeWindowLocalState(JSON.parse(raw) as unknown)
|
||||
} catch {
|
||||
return createEmptyWindowLocalState()
|
||||
}
|
||||
}
|
||||
|
||||
private async persistState(): Promise<void> {
|
||||
const state = normalizeWindowLocalState(this.state)
|
||||
const payload = `${JSON.stringify(state, null, 2)}\n`
|
||||
|
||||
this.writeChain = this.writeChain
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await mkdir(dirname(this.localStatePath), { recursive: true })
|
||||
await writeFile(this.localStatePath, payload, 'utf8')
|
||||
})
|
||||
|
||||
await this.writeChain
|
||||
}
|
||||
}
|
||||
@@ -47,16 +47,11 @@ 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 themeId = useSettingsStore((s) => s.themeId)
|
||||
const setThemeId = useSettingsStore((s) => s.setThemeId)
|
||||
const {
|
||||
themes,
|
||||
activeThemeId,
|
||||
loadTheme,
|
||||
renameTheme,
|
||||
deleteTheme,
|
||||
reloadThemes,
|
||||
importThemeFromDialog,
|
||||
showThemesFolder,
|
||||
} = useThemeStore()
|
||||
const astraState = useAstraStore((s) => s.integrationState)
|
||||
@@ -161,26 +156,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
setThemeId(value)
|
||||
}
|
||||
|
||||
const handleRenameTheme = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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)
|
||||
}
|
||||
|
||||
const handleSaveAstraConfig = async (): Promise<void> => {
|
||||
const nextConfig: AstraIntegrationConfig = {
|
||||
baseUrl: astraBaseUrlInput,
|
||||
@@ -264,27 +239,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
</option>
|
||||
))}
|
||||
</ThemedSelect>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await importThemeFromDialog()
|
||||
setThemeId(useThemeStore.getState().activeThemeId)
|
||||
})()
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
onClick={() => {
|
||||
void reloadThemes()
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
@@ -294,32 +248,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
>
|
||||
Folder
|
||||
</button>
|
||||
{activeThemeId && activeThemeId !== 'theme_default' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
onClick={() => {
|
||||
void handleRenameTheme()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
) : null}
|
||||
{activeThemeId && activeThemeId !== 'theme_default' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
onClick={() => {
|
||||
void handleDeleteTheme()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
<div className="settings-status-pill">
|
||||
<span className="settings-status-pill__dot" />
|
||||
<span>{themeId ? 'Saved With Profile' : 'Not Linked'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -71,12 +71,13 @@ export function scopeSettingsToOptions(
|
||||
const s = settings as ScopeSettings['spectrum']
|
||||
const t = theme as ResolvedSpectrumTheme
|
||||
return {
|
||||
lineColor: t.primary,
|
||||
secondaryLineColor: t.secondary,
|
||||
lineColor: t.line,
|
||||
secondaryLineColor: t.sideLine,
|
||||
gradientColors: t.fillGradient,
|
||||
heatColors: t.heatColors,
|
||||
backgroundColor: t.background,
|
||||
gridColor: t.guides,
|
||||
labelColor: t.labels,
|
||||
fftSize: s.fftSize,
|
||||
tiltDbPerOctave: s.tiltDbPerOctave,
|
||||
heatmapFill: s.heatmap,
|
||||
@@ -91,9 +92,10 @@ export function scopeSettingsToOptions(
|
||||
const s = settings as ScopeSettings['oscilloscope']
|
||||
const t = theme as ResolvedOscilloscopeTheme
|
||||
return {
|
||||
lineColor: t.primary,
|
||||
lineColor: t.line,
|
||||
backgroundColor: t.background,
|
||||
gridColor: t.guides,
|
||||
gridMajorColor: t.guides,
|
||||
gridMinorColor: t.guidesSecondary,
|
||||
underfillColor: t.fill,
|
||||
pitchLock: s.pitchLock,
|
||||
underfillEnabled: s.underfillEnabled,
|
||||
@@ -105,13 +107,15 @@ export function scopeSettingsToOptions(
|
||||
const s = settings as ScopeSettings['vectorscope']
|
||||
const t = theme as ResolvedVectorscopeTheme
|
||||
return {
|
||||
lineColor: t.primary,
|
||||
lineColor: t.trace,
|
||||
backgroundColor: t.background,
|
||||
gridColor: t.guides,
|
||||
gridMajorColor: t.guides,
|
||||
gridMinorColor: t.guidesSecondary,
|
||||
labelColor: t.labels,
|
||||
bandColors: {
|
||||
low: t.lowBand,
|
||||
mid: t.midBand,
|
||||
high: t.highBand,
|
||||
low: t.bandLow,
|
||||
mid: t.bandMid,
|
||||
high: t.bandHigh,
|
||||
},
|
||||
mode: s.mode,
|
||||
multiband: s.multiband,
|
||||
@@ -124,8 +128,9 @@ export function scopeSettingsToOptions(
|
||||
const s = settings as ScopeSettings['spectrogram']
|
||||
const t = theme as ResolvedSpectrogramTheme
|
||||
return {
|
||||
lineColor: t.primary,
|
||||
lineColor: t.mono,
|
||||
heatColors: t.heatColors,
|
||||
backgroundColor: t.background,
|
||||
fftSize: s.fftSize,
|
||||
scrollSpeed: s.scrollSpeed,
|
||||
clarityMode: s.clarityMode,
|
||||
@@ -137,11 +142,13 @@ export function scopeSettingsToOptions(
|
||||
const s = settings as ScopeSettings['vumeter']
|
||||
const t = theme as ResolvedVUMeterTheme
|
||||
return {
|
||||
lineColor: t.primary,
|
||||
backgroundColor: t.background,
|
||||
lineColor: t.level,
|
||||
trackColor: t.track,
|
||||
peakColor: t.peak,
|
||||
clipColor: t.clip,
|
||||
scaleColor: t.guides,
|
||||
labelColor: t.text,
|
||||
scaleColor: t.scale,
|
||||
labelColor: t.labels,
|
||||
mode: s.mode,
|
||||
orientation: s.orientation,
|
||||
}
|
||||
@@ -150,10 +157,12 @@ export function scopeSettingsToOptions(
|
||||
const s = settings as ScopeSettings['lufsmeter']
|
||||
const t = theme as ResolvedLUFSMeterTheme
|
||||
return {
|
||||
lineColor: t.primary,
|
||||
backgroundColor: t.background,
|
||||
lineColor: t.level,
|
||||
trackColor: t.track,
|
||||
targetColor: t.target,
|
||||
scaleColor: t.guides,
|
||||
labelColor: t.text,
|
||||
scaleColor: t.scale,
|
||||
labelColor: t.labels,
|
||||
mode: s.mode,
|
||||
}
|
||||
}
|
||||
@@ -161,13 +170,14 @@ export function scopeSettingsToOptions(
|
||||
const s = settings as ScopeSettings['waveform']
|
||||
const t = theme as ResolvedWaveformTheme
|
||||
return {
|
||||
lineColor: t.primary,
|
||||
backgroundColor: t.background,
|
||||
lineColor: t.line,
|
||||
gridMajorColor: t.guides,
|
||||
gridMinorColor: t.guides,
|
||||
gridMinorColor: t.guidesSecondary,
|
||||
bandColors: {
|
||||
low: t.lowBand,
|
||||
mid: t.midBand,
|
||||
high: t.highBand,
|
||||
low: t.bandLow,
|
||||
mid: t.bandMid,
|
||||
high: t.bandHigh,
|
||||
},
|
||||
mode: s.mode,
|
||||
scrollSpeed: s.scrollSpeed,
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState, useEffect, useCallback, useRef, type CSSProperties, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
function SettingsIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M8 1.7v2M8 12.3v2M14.3 8h-2M3.7 8h-2M12.4 3.6l-1.4 1.4M5 11l-1.4 1.4M12.4 12.4 11 11M5 5 3.6 3.6" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<g fill="currentColor">
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(60 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(120 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(180 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(240 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(300 8 8)" />
|
||||
</g>
|
||||
<circle cx="8" cy="8" r="3.45" fill="none" stroke="currentColor" strokeWidth="1.85" />
|
||||
<circle cx="8" cy="8" r="1.5" fill="none" stroke="currentColor" strokeWidth="1.05" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -70,6 +78,11 @@ interface ToolbarProps {
|
||||
|
||||
const DEFAULT_PROFILE_ID = 'profile_default'
|
||||
|
||||
function isToolbarInteractiveTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element
|
||||
&& target.closest('button, .toolbar__reposition-menu') !== null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
@@ -88,13 +101,13 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
|
||||
const importProfileFromDialog = useSettingsStore((s) => s.importProfileFromDialog)
|
||||
const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder)
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
|
||||
const [showReposition, setShowReposition] = useState(false)
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
|
||||
const profileButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
|
||||
void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
|
||||
const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
@@ -284,10 +297,30 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
|
||||
const handleToolbarDragStart = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
window.electronAPI.startWindowMove()
|
||||
}, [])
|
||||
|
||||
const handleToolbarDragEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
|
||||
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
|
||||
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div
|
||||
className="toolbar"
|
||||
onPointerDown={handleToolbarDragStart}
|
||||
onPointerUp={handleToolbarDragEnd}
|
||||
onPointerCancel={handleToolbarDragEnd}
|
||||
onLostPointerCapture={handleToolbarDragEnd}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__grab"
|
||||
@@ -303,7 +336,6 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
|
||||
<div
|
||||
className="toolbar__brand"
|
||||
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
|
||||
>
|
||||
<span className="toolbar__brand-mark" />
|
||||
<span className="toolbar__brand-text">Prism</span>
|
||||
@@ -325,10 +357,7 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="toolbar__spacer"
|
||||
style={{ WebkitAppRegion: 'drag' } as CSSProperties}
|
||||
/>
|
||||
<div className="toolbar__spacer" />
|
||||
|
||||
<div className="toolbar__actions">
|
||||
<div className="toolbar__reposition-wrap">
|
||||
|
||||
@@ -21,8 +21,24 @@ function PopInIcon(): JSX.Element {
|
||||
function SettingsIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="2.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M8 1.8v1.7M8 12.5v1.7M14.2 8h-1.7M3.5 8H1.8M12.2 3.8l-1.2 1.2M5 11l-1.2 1.2M12.2 12.2 11 11M5 5 3.8 3.8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<g fill="currentColor">
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(60 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(120 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(180 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(240 8 8)" />
|
||||
<rect x="7.15" y="1.15" width="1.7" height="2.7" rx="0.45" transform="rotate(300 8 8)" />
|
||||
</g>
|
||||
<circle cx="8" cy="8" r="3.45" fill="none" stroke="currentColor" strokeWidth="1.85" />
|
||||
<circle cx="8" cy="8" r="1.5" fill="none" stroke="currentColor" strokeWidth="1.05" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PinIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M10.9 2.5 13 4.6 10.8 7v2.2l-1 1L8 8.4 4.8 11.6 4 10.8l3.2-3.2-1.8-1.8 1-1H8.6z" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -49,12 +65,19 @@ const defaultTheme = resolveTheme(createDefaultTheme())
|
||||
|
||||
export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element {
|
||||
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
|
||||
const [miniSettingsOpen, setMiniSettingsOpen] = useState(false)
|
||||
const prevMiniSettingsOpenRef = useRef(false)
|
||||
const frameTarget = usePerformanceStore((s) => s.frameTarget)
|
||||
const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
|
||||
const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind])
|
||||
|
||||
useEffect(() => {
|
||||
void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
|
||||
const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
frameScheduler.setFrameTarget(frameTarget)
|
||||
}, [frameScheduler, frameTarget])
|
||||
@@ -191,6 +214,15 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
|
||||
</div>
|
||||
|
||||
<div className="scope-popout__actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-popout__button ${isAlwaysOnTop ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => window.electronAPI.toggleAlwaysOnTop()}
|
||||
aria-label={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
|
||||
title={isAlwaysOnTop ? 'Unpin from top' : 'Pin to top'}
|
||||
>
|
||||
<PinIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`scope-popout__button ${miniSettingsOpen ? 'is-active' : ''}`.trim()}
|
||||
|
||||
+114
-103
@@ -38,11 +38,23 @@
|
||||
--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);
|
||||
--control-text: rgba(255, 255, 255, 0.92);
|
||||
--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);
|
||||
--option-bg: rgba(8, 11, 16, 0.96);
|
||||
--option-text: rgba(245, 247, 250, 1);
|
||||
--slider-track: rgba(255, 255, 255, 0.08);
|
||||
--slider-fill: rgba(56, 189, 248, 0.82);
|
||||
--slider-thumb: rgba(125, 211, 252, 1);
|
||||
--divider: rgba(255, 255, 255, 0.08);
|
||||
--scope-bg: #000000;
|
||||
--scope-guides: rgba(255, 255, 255, 0.1);
|
||||
--scope-overlay-bg: rgba(8, 12, 18, 0.88);
|
||||
--scope-overlay-text: rgba(255, 255, 255, 0.76);
|
||||
--scope-overlay-border: rgba(255, 255, 255, 0.12);
|
||||
--scope-resize-handle: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -57,7 +69,7 @@ body,
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -190,8 +202,8 @@ select {
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, var(--settings-bg-top), var(--settings-bg-bottom)),
|
||||
rgba(0, 0, 0, 0.96);
|
||||
border-top: 1px solid rgba(var(--accent-rgb), 0.14);
|
||||
var(--panel-surface-soft);
|
||||
border-top: 1px solid var(--divider);
|
||||
box-shadow: 0 -16px 34px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
@@ -239,9 +251,9 @@ select {
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(255, 255, 255, 0.02));
|
||||
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
|
||||
border: 1px solid var(--control-border-active);
|
||||
background: linear-gradient(180deg, var(--control-bg-active), var(--panel-surface));
|
||||
box-shadow: inset 0 1px 0 var(--glass-highlight);
|
||||
}
|
||||
|
||||
.toolbar__brand-mark {
|
||||
@@ -285,7 +297,7 @@ select {
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
@@ -300,9 +312,9 @@ select {
|
||||
|
||||
.toolbar__profile-button:hover,
|
||||
.toolbar__profile-button.is-active {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(var(--accent-rgb), 0.26);
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--control-text);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-active);
|
||||
}
|
||||
|
||||
.toolbar__profile-name {
|
||||
@@ -317,7 +329,7 @@ select {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 8px rgba(255, 191, 0, 0.35);
|
||||
box-shadow: 0 0 8px var(--warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -325,7 +337,6 @@ select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.toolbar__reposition-wrap {
|
||||
@@ -340,7 +351,7 @@ select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--menu-bg);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
border: 1px solid var(--menu-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
@@ -370,7 +381,7 @@ select {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-tertiary);
|
||||
@@ -384,16 +395,16 @@ select {
|
||||
}
|
||||
|
||||
.toolbar__chip:hover {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
color: var(--control-text);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.toolbar__chip.is-active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.26);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.08);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-active);
|
||||
box-shadow: inset 0 0 0 1px var(--glass-highlight);
|
||||
}
|
||||
|
||||
.toolbar__actions {
|
||||
@@ -406,10 +417,10 @@ select {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -427,29 +438,29 @@ select {
|
||||
}
|
||||
|
||||
.toolbar__icon-button:hover {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
color: var(--control-text);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toolbar__icon-button.is-active {
|
||||
color: var(--accent);
|
||||
border-color: rgba(var(--accent-rgb), 0.24);
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-active);
|
||||
}
|
||||
|
||||
.toolbar__icon-button--danger:hover {
|
||||
color: var(--danger);
|
||||
border-color: rgba(248, 113, 113, 0.22);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border-color: var(--danger);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.scope-strip {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
background-color: var(--scope-bg);
|
||||
}
|
||||
|
||||
.scope-strip__grid {
|
||||
@@ -484,9 +495,9 @@ select {
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(8, 12, 18, 0.88);
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid var(--scope-overlay-border);
|
||||
background: var(--scope-overlay-bg);
|
||||
color: var(--scope-overlay-text);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -535,7 +546,7 @@ select {
|
||||
}
|
||||
|
||||
.scope-strip__reorder-button:disabled {
|
||||
color: rgba(255, 255, 255, 0.32);
|
||||
color: var(--text-muted);
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -568,7 +579,7 @@ select {
|
||||
.scope-strip__resize-handle-grip {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
background: linear-gradient(180deg, transparent, var(--scope-resize-handle), transparent);
|
||||
}
|
||||
|
||||
.astra-scope {
|
||||
@@ -830,7 +841,7 @@ select {
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background: linear-gradient(180deg, rgba(7, 10, 15, 0.95), rgba(4, 6, 10, 0.98));
|
||||
background: linear-gradient(180deg, var(--settings-bg-top), var(--settings-bg-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -878,7 +889,7 @@ select {
|
||||
|
||||
.settings-section-title {
|
||||
width: 100%;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -888,7 +899,7 @@ select {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 16px 14px;
|
||||
border-left: 1px solid rgba(var(--accent-rgb), 0.12);
|
||||
border-left: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.settings-scope-section:first-child {
|
||||
@@ -902,7 +913,7 @@ select {
|
||||
gap: 12px;
|
||||
padding: 10px 0 12px;
|
||||
min-height: 42px;
|
||||
border-bottom: 1px solid rgba(var(--accent-rgb), 0.14);
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.settings-scope-section__title {
|
||||
@@ -913,7 +924,7 @@ select {
|
||||
|
||||
.settings-scope-section__summary {
|
||||
flex: 1;
|
||||
color: rgba(255, 255, 255, 0.44);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
@@ -965,46 +976,50 @@ select {
|
||||
|
||||
.settings-control__select {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
padding-right: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.1), rgba(7, 10, 15, 0.96));
|
||||
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: var(--control-text);
|
||||
font-size: 11px;
|
||||
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.settings-control__select:hover,
|
||||
.settings-control__select:focus-within {
|
||||
border-color: rgba(var(--accent-rgb), 0.34);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(8, 12, 18, 0.98));
|
||||
color: var(--text-primary);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--control-text);
|
||||
}
|
||||
|
||||
.themed-select__control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
min-height: 32px;
|
||||
padding: 0 36px 0 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
.themed-select__control option,
|
||||
.themed-select__control optgroup {
|
||||
background: rgb(10, 14, 20);
|
||||
color: rgb(245, 247, 250);
|
||||
background: var(--option-bg);
|
||||
color: var(--option-text);
|
||||
}
|
||||
|
||||
.themed-select__control optgroup {
|
||||
@@ -1049,10 +1064,10 @@ select {
|
||||
.settings-control__range::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--control-border);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(var(--accent-rgb), 0.82), rgba(var(--accent-rgb), 0.46)) 0 / var(--range-percent) 100% no-repeat,
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04));
|
||||
linear-gradient(90deg, var(--slider-fill), var(--slider-fill)) 0 / var(--range-percent) 100% no-repeat,
|
||||
linear-gradient(180deg, var(--slider-track), rgba(255, 255, 255, 0.04));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.22);
|
||||
@@ -1064,8 +1079,8 @@ select {
|
||||
height: 16px;
|
||||
margin-top: -6px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.78);
|
||||
background: linear-gradient(180deg, rgba(20, 26, 34, 0.98), rgba(7, 10, 15, 0.98));
|
||||
border: 1px solid var(--control-border-active);
|
||||
background: var(--slider-thumb);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
0 0 0 2px rgba(var(--accent-rgb), 0.14),
|
||||
@@ -1075,8 +1090,8 @@ select {
|
||||
.settings-control__range::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--control-border);
|
||||
background: linear-gradient(180deg, var(--slider-track), rgba(255, 255, 255, 0.04));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.22);
|
||||
@@ -1086,8 +1101,8 @@ select {
|
||||
width: 12px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.78);
|
||||
background: linear-gradient(180deg, rgba(20, 26, 34, 0.98), rgba(7, 10, 15, 0.98));
|
||||
border: 1px solid var(--control-border-active);
|
||||
background: var(--slider-thumb);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
0 0 0 2px rgba(var(--accent-rgb), 0.14),
|
||||
@@ -1098,19 +1113,19 @@ select {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.02);
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.82), rgba(var(--accent-rgb), 0.46));
|
||||
background: linear-gradient(90deg, var(--slider-fill), var(--slider-fill));
|
||||
}
|
||||
|
||||
.settings-control__range:hover::-webkit-slider-runnable-track,
|
||||
.settings-control__range:focus-visible::-webkit-slider-runnable-track {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(var(--accent-rgb), 0.92), rgba(var(--accent-rgb), 0.58)) 0 / var(--range-percent) 100% no-repeat,
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
|
||||
linear-gradient(90deg, var(--slider-fill), var(--slider-fill)) 0 / var(--range-percent) 100% no-repeat,
|
||||
linear-gradient(180deg, var(--control-bg-hover), var(--slider-track));
|
||||
}
|
||||
|
||||
.settings-control__range:hover::-moz-range-progress,
|
||||
.settings-control__range:focus-visible::-moz-range-progress {
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.92), rgba(var(--accent-rgb), 0.58));
|
||||
background: linear-gradient(90deg, var(--slider-fill), var(--slider-fill));
|
||||
}
|
||||
|
||||
.settings-control__range:hover::-webkit-slider-thumb,
|
||||
@@ -1145,7 +1160,7 @@ select {
|
||||
min-height: 28px;
|
||||
padding: 0 11px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--control-bg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
@@ -1158,16 +1173,16 @@ select {
|
||||
}
|
||||
|
||||
.settings-chip:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
color: var(--control-text);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.settings-chip.is-active {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(var(--accent-rgb), 0.34);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.1));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
color: var(--control-text);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-active);
|
||||
box-shadow: inset 0 1px 0 var(--glass-highlight);
|
||||
}
|
||||
|
||||
.settings-status-pill {
|
||||
@@ -1177,9 +1192,9 @@ select {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(7, 10, 15, 0.96));
|
||||
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
|
||||
border: 1px solid var(--control-border);
|
||||
background: linear-gradient(180deg, var(--panel-surface), var(--panel-surface-soft));
|
||||
box-shadow: inset 0 1px 0 var(--glass-highlight);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
@@ -1194,42 +1209,38 @@ select {
|
||||
|
||||
.settings-status-pill.is-connecting .settings-status-pill__dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.3);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-connecting {
|
||||
border-color: rgba(var(--accent-rgb), 0.32);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(7, 10, 15, 0.96));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-capturing .settings-status-pill__dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-capturing {
|
||||
border-color: rgba(34, 197, 94, 0.34);
|
||||
background: linear-gradient(180deg, rgba(34, 197, 94, 0.14), rgba(7, 10, 15, 0.96));
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-connected .settings-status-pill__dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.34);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-connected {
|
||||
border-color: rgba(34, 197, 94, 0.28);
|
||||
background: linear-gradient(180deg, rgba(34, 197, 94, 0.12), rgba(7, 10, 15, 0.96));
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-error .settings-status-pill__dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 8px rgba(248, 113, 113, 0.36);
|
||||
box-shadow: 0 0 8px var(--danger);
|
||||
}
|
||||
|
||||
.settings-status-pill.is-error {
|
||||
border-color: rgba(248, 113, 113, 0.34);
|
||||
background: linear-gradient(180deg, rgba(248, 113, 113, 0.14), rgba(7, 10, 15, 0.96));
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.settings-error-text {
|
||||
@@ -1255,7 +1266,7 @@ select {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--divider);
|
||||
background: var(--swatch-color);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.34);
|
||||
@@ -1263,13 +1274,13 @@ select {
|
||||
}
|
||||
|
||||
.settings-swatch:hover {
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
border-color: var(--control-border-active);
|
||||
}
|
||||
|
||||
.settings-swatch.is-active {
|
||||
border-color: rgba(255, 255, 255, 0.88);
|
||||
border-color: var(--control-border-active);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.88),
|
||||
0 0 0 1px var(--control-border-active),
|
||||
0 0 0 4px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
@@ -1284,8 +1295,8 @@ select {
|
||||
width: 84px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
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));
|
||||
cursor: pointer;
|
||||
padding: 3px 4px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
@@ -1319,7 +1330,7 @@ select {
|
||||
min-height: 92px;
|
||||
padding: 0;
|
||||
background: var(--bottom-bar-bg);
|
||||
border-top: 1px solid rgba(var(--accent-rgb), 0.14);
|
||||
border-top: 1px solid var(--divider);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1401,9 +1412,9 @@ select {
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.08), rgba(255, 255, 255, 0.02));
|
||||
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
|
||||
border: 1px solid var(--control-border);
|
||||
background: linear-gradient(180deg, var(--panel-surface), var(--control-bg));
|
||||
box-shadow: inset 0 1px 0 var(--glass-highlight);
|
||||
}
|
||||
|
||||
.bottom-bar__inline--theme {
|
||||
@@ -1424,7 +1435,7 @@ select {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 0 16px 0 0;
|
||||
background: linear-gradient(180deg, transparent, rgba(var(--accent-rgb), 0.18) 18%, rgba(var(--accent-rgb), 0.08) 82%, transparent);
|
||||
background: linear-gradient(180deg, transparent, var(--divider) 18%, var(--divider) 82%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1499,8 +1510,8 @@ select {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 14px 10px 16px;
|
||||
border-left: 1px solid rgba(var(--accent-rgb), 0.14);
|
||||
background: linear-gradient(90deg, rgba(2, 4, 7, 0.76), var(--bottom-bar-bg) 24%, var(--bottom-bar-bg));
|
||||
border-left: 1px solid var(--divider);
|
||||
background: linear-gradient(90deg, transparent, var(--bottom-bar-bg) 28%, var(--bottom-bar-bg));
|
||||
}
|
||||
|
||||
.bottom-bar__close {
|
||||
@@ -1713,10 +1724,10 @@ select {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: var(--scope-guides);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: var(--scope-overlay-border);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface RgbColor {
|
||||
b: number
|
||||
}
|
||||
|
||||
export interface RgbaColor extends RgbColor {
|
||||
a: number
|
||||
}
|
||||
|
||||
export const DEFAULT_VISUALIZER_TINT: RgbColor = { r: 56, g: 189, b: 248 }
|
||||
|
||||
function clampByte(value: number): number {
|
||||
@@ -25,6 +29,23 @@ function parseRgbToken(value: string): number | null {
|
||||
return clampByte(numeric)
|
||||
}
|
||||
|
||||
function parseAlphaToken(value: string): number | null {
|
||||
const token = value.trim()
|
||||
if (!token) return null
|
||||
|
||||
if (token.endsWith('%')) {
|
||||
const percent = Number.parseFloat(token.slice(0, -1))
|
||||
if (!Number.isFinite(percent)) return null
|
||||
return Math.max(0, Math.min(1, percent / 100))
|
||||
}
|
||||
|
||||
const numeric = Number.parseFloat(token)
|
||||
if (!Number.isFinite(numeric)) return null
|
||||
return numeric > 1
|
||||
? Math.max(0, Math.min(1, numeric / 255))
|
||||
: Math.max(0, Math.min(1, numeric))
|
||||
}
|
||||
|
||||
export function parseColorToRgb(color: string): RgbColor | null {
|
||||
const normalizedColor = color.trim()
|
||||
|
||||
@@ -71,6 +92,62 @@ export function parseColorToRgb(color: string): RgbColor | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseColorToRgba(color: string): RgbaColor | null {
|
||||
const normalizedColor = color.trim()
|
||||
|
||||
if (normalizedColor.startsWith('#')) {
|
||||
const hex = normalizedColor.slice(1)
|
||||
const normalizedHex = hex.length === 3 || hex.length === 4
|
||||
? hex.split('').map((ch) => `${ch}${ch}`).join('')
|
||||
: hex
|
||||
|
||||
if (normalizedHex.length !== 6 && normalizedHex.length !== 8) return null
|
||||
|
||||
const r = Number.parseInt(normalizedHex.slice(0, 2), 16)
|
||||
const g = Number.parseInt(normalizedHex.slice(2, 4), 16)
|
||||
const b = Number.parseInt(normalizedHex.slice(4, 6), 16)
|
||||
const a = normalizedHex.length === 8
|
||||
? Number.parseInt(normalizedHex.slice(6, 8), 16) / 255
|
||||
: 1
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null
|
||||
return { r, g, b, a }
|
||||
}
|
||||
|
||||
const rgbMatch = /^rgba?\((.*)\)$/i.exec(normalizedColor)
|
||||
if (!rgbMatch) return null
|
||||
|
||||
const rawBody = rgbMatch[1]?.trim()
|
||||
if (!rawBody) return null
|
||||
|
||||
const [colorPart, alphaPart] = rawBody.includes('/')
|
||||
? rawBody.split('/', 2)
|
||||
: [rawBody, undefined]
|
||||
|
||||
const tokens = colorPart.includes(',')
|
||||
? colorPart.split(',').map((token) => token.trim())
|
||||
: colorPart.split(/\s+/).filter(Boolean)
|
||||
if (tokens.length < 3) return null
|
||||
|
||||
const r = parseRgbToken(tokens[0])
|
||||
const g = parseRgbToken(tokens[1])
|
||||
const b = parseRgbToken(tokens[2])
|
||||
if (r === null || g === null || b === null) return null
|
||||
|
||||
const a = alphaPart
|
||||
? parseAlphaToken(alphaPart)
|
||||
: tokens[3]
|
||||
? parseAlphaToken(tokens[3])
|
||||
: 1
|
||||
if (a === null) return null
|
||||
|
||||
return {
|
||||
r: Math.round(r),
|
||||
g: Math.round(g),
|
||||
b: Math.round(b),
|
||||
a,
|
||||
}
|
||||
}
|
||||
|
||||
export function colorToRgbChannels(color: string): string | null {
|
||||
const rgb = parseColorToRgb(color)
|
||||
if (!rgb) return null
|
||||
@@ -80,3 +157,13 @@ export function colorToRgbChannels(color: string): string | null {
|
||||
export function resolveColorToRgb(color: string, fallback: RgbColor = DEFAULT_VISUALIZER_TINT): RgbColor {
|
||||
return parseColorToRgb(color) ?? fallback
|
||||
}
|
||||
|
||||
export function multiplyColorAlpha(color: string, factor: number): string {
|
||||
const parsed = parseColorToRgba(color)
|
||||
if (!parsed) return color
|
||||
const alpha = Math.max(0, Math.min(1, parsed.a * factor))
|
||||
if (alpha >= 0.999) {
|
||||
return `rgb(${parsed.r}, ${parsed.g}, ${parsed.b})`
|
||||
}
|
||||
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${Number(alpha.toFixed(3))})`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ export interface LUFSMeterDataSource extends VisualizerSessionSource {
|
||||
|
||||
export interface LUFSMeterOptions {
|
||||
mode?: LUFSMeterMode
|
||||
backgroundColor?: string
|
||||
lineColor?: string
|
||||
trackColor?: string
|
||||
targetColor?: string
|
||||
scaleColor?: string
|
||||
labelColor?: string
|
||||
@@ -23,7 +25,9 @@ type ResolvedLUFSMeterOptions = Required<Omit<LUFSMeterOptions, 'dataSource' | '
|
||||
|
||||
const defaultOptions: ResolvedLUFSMeterOptions = {
|
||||
mode: 'bar',
|
||||
backgroundColor: 'transparent',
|
||||
lineColor: '#38bdf8',
|
||||
trackColor: 'rgba(56, 189, 248, 0.08)',
|
||||
targetColor: 'rgba(56, 189, 248, 0.25)',
|
||||
scaleColor: 'rgba(255, 255, 255, 0.35)',
|
||||
labelColor: 'rgba(255, 255, 255, 0.8)',
|
||||
@@ -396,7 +400,7 @@ export class LUFSMeter {
|
||||
}
|
||||
|
||||
private drawFrame = (): void => {
|
||||
const { canvas, ctx } = this
|
||||
const { canvas, ctx, options } = this
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
@@ -407,6 +411,10 @@ export class LUFSMeter {
|
||||
this.processAudio()
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
if (options.backgroundColor !== 'transparent') {
|
||||
ctx.fillStyle = options.backgroundColor
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
this.drawBars(width, height)
|
||||
}
|
||||
@@ -450,7 +458,7 @@ export class LUFSMeter {
|
||||
ctx.fillText(labels[i], x + barWidth / 2, padding)
|
||||
|
||||
// Bar background
|
||||
ctx.fillStyle = `rgba(${tintR}, ${tintG}, ${tintB}, 0.08)`
|
||||
ctx.fillStyle = this.options.trackColor
|
||||
ctx.fillRect(x, barAreaTop, barWidth, barAreaHeight)
|
||||
|
||||
// Bar fill — gradient from dim at bottom to bright at top
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isNativeAvailable
|
||||
} from '../audio/native'
|
||||
import { getNormalizedOscilloscopeDisplaySamples } from '../audio/native/oscilloscopeDisplaySamples'
|
||||
import { colorToRgbChannels } from '../utils/color'
|
||||
import { colorToRgbChannels, multiplyColorAlpha } from '../utils/color'
|
||||
import { defaultVisualizerSessionSource, type VisualizerSessionSource } from './dataSource'
|
||||
import { FrameScheduler } from './frameScheduler'
|
||||
import { VisualizerFrameLoop } from './visualizerFrameLoop'
|
||||
@@ -19,7 +19,8 @@ export interface OscilloscopeOptions {
|
||||
lineWidth?: number
|
||||
backgroundColor?: string
|
||||
showGrid?: boolean
|
||||
gridColor?: string
|
||||
gridMajorColor?: string
|
||||
gridMinorColor?: string
|
||||
underfillColor?: string
|
||||
pitchLock?: boolean
|
||||
underfillEnabled?: boolean
|
||||
@@ -34,7 +35,8 @@ const defaultOptions: ResolvedOscilloscopeOptions = {
|
||||
lineWidth: 2,
|
||||
backgroundColor: 'transparent',
|
||||
showGrid: true,
|
||||
gridColor: 'rgba(255, 255, 255, 0.1)',
|
||||
gridMajorColor: 'rgba(255, 255, 255, 0.1)',
|
||||
gridMinorColor: 'rgba(255, 255, 255, 0.05)',
|
||||
underfillColor: 'rgba(245, 248, 252, 0.18)',
|
||||
pitchLock: true,
|
||||
underfillEnabled: false,
|
||||
@@ -294,7 +296,8 @@ export class Oscilloscope {
|
||||
canvas.height,
|
||||
options.backgroundColor,
|
||||
options.showGrid,
|
||||
options.gridColor,
|
||||
options.gridMajorColor,
|
||||
options.gridMinorColor,
|
||||
].join(':')
|
||||
|
||||
if (this.staticLayerKey === key) {
|
||||
@@ -323,7 +326,7 @@ export class Oscilloscope {
|
||||
const height = canvas.height
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
ctx.strokeStyle = options.gridColor
|
||||
ctx.strokeStyle = options.gridMajorColor
|
||||
ctx.lineWidth = dpr
|
||||
|
||||
ctx.beginPath()
|
||||
@@ -336,7 +339,7 @@ export class Oscilloscope {
|
||||
ctx.lineTo(width / 2, height)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.strokeStyle = options.gridColor.replace('0.1', '0.05')
|
||||
ctx.strokeStyle = options.gridMinorColor || multiplyColorAlpha(options.gridMajorColor, 0.5)
|
||||
for (let i = 1; i < 4; i++) {
|
||||
if (i === 2) continue
|
||||
ctx.beginPath()
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface SpectrogramOptions {
|
||||
colorScheme?: 'heat' | 'mono'
|
||||
lineColor?: string
|
||||
heatColors?: [string, string, string]
|
||||
backgroundColor?: string
|
||||
dataSource?: SpectrogramDataSource
|
||||
frameScheduler?: FrameScheduler
|
||||
}
|
||||
@@ -54,6 +55,7 @@ const defaultOptions: ResolvedSpectrogramOptions = {
|
||||
colorScheme: 'heat',
|
||||
lineColor: '#38bdf8',
|
||||
heatColors: ['rgb(15, 7, 33)', 'rgb(163, 26, 121)', 'rgb(255, 241, 209)'],
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
|
||||
const defaultSpectrogramDataSource: SpectrogramDataSource = {
|
||||
@@ -95,6 +97,7 @@ function resolveOptions(base: ResolvedSpectrogramOptions, overrides: Partial<Spe
|
||||
colorScheme: overrides.colorScheme ?? base.colorScheme,
|
||||
lineColor: overrides.lineColor ?? base.lineColor,
|
||||
heatColors: overrides.heatColors ?? base.heatColors,
|
||||
backgroundColor: overrides.backgroundColor ?? base.backgroundColor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,6 +700,10 @@ export class Spectrogram {
|
||||
this.dataSource.getPendingSpectrogramSamples()
|
||||
// Freeze waterfall in place instead of blanking
|
||||
this.ctx.clearRect(0, 0, width, height)
|
||||
if (this.options.backgroundColor !== 'transparent') {
|
||||
this.ctx.fillStyle = this.options.backgroundColor
|
||||
this.ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
|
||||
return
|
||||
}
|
||||
@@ -728,6 +735,10 @@ export class Spectrogram {
|
||||
}
|
||||
|
||||
this.ctx.clearRect(0, 0, width, height)
|
||||
if (this.options.backgroundColor !== 'transparent') {
|
||||
this.ctx.fillStyle = this.options.backgroundColor
|
||||
this.ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
this.ctx.drawImage(this.waterfallCanvas, 0, 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export interface VUMeterDataSource extends VisualizerSessionSource {
|
||||
export interface VUMeterOptions {
|
||||
mode?: VUMeterMode
|
||||
orientation?: VUMeterOrientation
|
||||
backgroundColor?: string
|
||||
lineColor?: string
|
||||
trackColor?: string
|
||||
peakColor?: string
|
||||
clipColor?: string
|
||||
scaleColor?: string
|
||||
@@ -36,7 +38,9 @@ type ResolvedVUMeterOptions = Required<Omit<VUMeterOptions, 'dataSource' | 'fram
|
||||
const defaultOptions: ResolvedVUMeterOptions = {
|
||||
mode: 'bar',
|
||||
orientation: DEFAULT_VU_METER_ORIENTATION,
|
||||
backgroundColor: 'transparent',
|
||||
lineColor: '#38bdf8',
|
||||
trackColor: 'rgba(56, 189, 248, 0.08)',
|
||||
peakColor: 'rgb(255, 127, 0)',
|
||||
clipColor: 'rgba(255, 120, 80, 0.9)',
|
||||
scaleColor: 'rgba(255, 255, 255, 0.12)',
|
||||
@@ -258,7 +262,7 @@ export class VUMeter {
|
||||
const hotThreshold = this.dbToNormalized(-6) * w
|
||||
|
||||
// Background track
|
||||
ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25)
|
||||
ctx.fillStyle = this.options.trackColor
|
||||
ctx.fillRect(x, y, w, h)
|
||||
|
||||
// Main level bar
|
||||
@@ -310,7 +314,7 @@ export class VUMeter {
|
||||
const levelHeight = levelNorm * h
|
||||
const hotThreshold = this.dbToNormalized(-6) * h
|
||||
|
||||
ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25)
|
||||
ctx.fillStyle = this.options.trackColor
|
||||
ctx.fillRect(x, y, w, h)
|
||||
|
||||
if (levelHeight > 0) {
|
||||
@@ -396,7 +400,7 @@ export class VUMeter {
|
||||
const corr = Math.max(-1, Math.min(1, this.correlation))
|
||||
|
||||
// Background track
|
||||
ctx.fillStyle = alphaColor(this.options.scaleColor, 0.25)
|
||||
ctx.fillStyle = this.options.trackColor
|
||||
ctx.fillRect(x, y, w, h)
|
||||
|
||||
// Center line
|
||||
@@ -561,6 +565,10 @@ export class VUMeter {
|
||||
this.processAudio()
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
if (options.backgroundColor !== 'transparent') {
|
||||
ctx.fillStyle = options.backgroundColor
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
if (options.mode === 'needle') {
|
||||
this.drawNeedleMode(width, height)
|
||||
|
||||
@@ -17,7 +17,9 @@ export interface VectorscopeOptions {
|
||||
lineWidth?: number
|
||||
backgroundColor?: string
|
||||
showGrid?: boolean
|
||||
gridColor?: string
|
||||
gridMajorColor?: string
|
||||
gridMinorColor?: string
|
||||
labelColor?: string
|
||||
bandColors?: {
|
||||
low: string
|
||||
mid: string
|
||||
@@ -38,7 +40,9 @@ const defaultOptions: ResolvedVectorscopeOptions = {
|
||||
lineWidth: 1.5,
|
||||
backgroundColor: 'transparent',
|
||||
showGrid: true,
|
||||
gridColor: 'rgba(255, 255, 255, 0.1)',
|
||||
gridMajorColor: 'rgba(255, 255, 255, 0.1)',
|
||||
gridMinorColor: 'rgba(255, 255, 255, 0.05)',
|
||||
labelColor: 'rgba(255, 255, 255, 0.1)',
|
||||
bandColors: {
|
||||
low: '#ff4444',
|
||||
mid: '#44dd44',
|
||||
@@ -247,7 +251,9 @@ export class Vectorscope {
|
||||
canvas.height,
|
||||
options.backgroundColor,
|
||||
options.showGrid,
|
||||
options.gridColor,
|
||||
options.gridMajorColor,
|
||||
options.gridMinorColor,
|
||||
options.labelColor,
|
||||
options.mode,
|
||||
].join(':')
|
||||
|
||||
@@ -266,7 +272,16 @@ export class Vectorscope {
|
||||
|
||||
if (options.showGrid) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
drawVectorscopeGridForMode(this.staticLayerCtx, canvas.width, canvas.height, options.gridColor, options.mode, dpr)
|
||||
drawVectorscopeGridForMode(
|
||||
this.staticLayerCtx,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
options.gridMajorColor,
|
||||
options.gridMinorColor,
|
||||
options.labelColor,
|
||||
options.mode,
|
||||
dpr,
|
||||
)
|
||||
}
|
||||
|
||||
this.staticLayerKey = key
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface WaveformDataSource extends VisualizerSessionSource {
|
||||
}
|
||||
|
||||
export interface WaveformOptions {
|
||||
backgroundColor?: string
|
||||
lineColor?: string
|
||||
gridMajorColor?: string
|
||||
gridMinorColor?: string
|
||||
@@ -43,6 +44,7 @@ export interface WaveformOptions {
|
||||
type ResolvedWaveformOptions = Required<Omit<WaveformOptions, 'dataSource' | 'frameScheduler'>>
|
||||
|
||||
const defaultOptions: ResolvedWaveformOptions = {
|
||||
backgroundColor: 'transparent',
|
||||
lineColor: '#38bdf8',
|
||||
gridMajorColor: 'rgba(255, 255, 255, 0.08)',
|
||||
gridMinorColor: 'rgba(255, 255, 255, 0.04)',
|
||||
@@ -377,7 +379,7 @@ export class Waveform {
|
||||
}
|
||||
|
||||
private ensureStaticLayer(width: number, height: number): void {
|
||||
const key = `${width}:${height}:${this.options.mode}`
|
||||
const key = `${width}:${height}:${this.options.mode}:${this.options.backgroundColor}`
|
||||
if (this.staticLayerKey === key) {
|
||||
return
|
||||
}
|
||||
@@ -385,6 +387,10 @@ export class Waveform {
|
||||
this.staticLayerCanvas.width = this.canvas.width
|
||||
this.staticLayerCanvas.height = this.canvas.height
|
||||
this.staticLayerCtx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
if (this.options.backgroundColor !== 'transparent') {
|
||||
this.staticLayerCtx.fillStyle = this.options.backgroundColor
|
||||
this.staticLayerCtx.fillRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
}
|
||||
this.drawGrid(this.staticLayerCtx, width, height)
|
||||
this.staticLayerKey = key
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { VectorscopeMode } from './Vectorscope'
|
||||
import { multiplyColorAlpha } from '../utils/color'
|
||||
|
||||
const INV_SQRT2 = 1 / Math.sqrt(2)
|
||||
const COS45 = Math.SQRT2 / 2 // 0.7071...
|
||||
@@ -91,12 +92,14 @@ export function transformPoint(
|
||||
export function drawLissajousGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layout: VectorscopeLayout,
|
||||
gridColor: string,
|
||||
gridMajorColor: string,
|
||||
gridMinorColor: string,
|
||||
labelColor: string,
|
||||
dpr: number
|
||||
): void {
|
||||
const { centerX, centerY, radius } = layout
|
||||
|
||||
ctx.strokeStyle = gridColor
|
||||
ctx.strokeStyle = gridMajorColor
|
||||
ctx.lineWidth = dpr
|
||||
|
||||
// Outer box
|
||||
@@ -120,8 +123,7 @@ export function drawLissajousGrid(
|
||||
ctx.stroke()
|
||||
|
||||
// Diagonal guides (dimmer)
|
||||
const dimColor = gridColor.replace(/[\d.]+\)$/, (m) => `${parseFloat(m) * 0.5})`)
|
||||
ctx.strokeStyle = dimColor
|
||||
ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(centerX - radius, centerY - radius)
|
||||
@@ -134,7 +136,7 @@ export function drawLissajousGrid(
|
||||
ctx.stroke()
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = gridColor
|
||||
ctx.fillStyle = labelColor
|
||||
ctx.font = `${10 * dpr}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('L', centerX, centerY - radius - 6 * dpr)
|
||||
@@ -147,13 +149,15 @@ export function drawLissajousGrid(
|
||||
export function drawPolarGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layout: VectorscopeLayout,
|
||||
gridColor: string,
|
||||
gridMajorColor: string,
|
||||
gridMinorColor: string,
|
||||
labelColor: string,
|
||||
unipolar: boolean,
|
||||
dpr: number
|
||||
): void {
|
||||
const { centerX, centerY, radius } = layout
|
||||
|
||||
ctx.strokeStyle = gridColor
|
||||
ctx.strokeStyle = gridMajorColor
|
||||
ctx.lineWidth = dpr
|
||||
|
||||
// Concentric circles (or semicircles for unipolar)
|
||||
@@ -185,8 +189,7 @@ export function drawPolarGrid(
|
||||
ctx.stroke()
|
||||
|
||||
// Diagonal guides (L and R channel axes) — dimmer
|
||||
const dimColor = gridColor.replace(/[\d.]+\)$/, (m) => `${parseFloat(m) * 0.5})`)
|
||||
ctx.strokeStyle = dimColor
|
||||
ctx.strokeStyle = gridMinorColor || multiplyColorAlpha(gridMajorColor, 0.5)
|
||||
|
||||
if (unipolar) {
|
||||
ctx.beginPath()
|
||||
@@ -211,7 +214,7 @@ export function drawPolarGrid(
|
||||
}
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = gridColor
|
||||
ctx.fillStyle = labelColor
|
||||
ctx.font = `${10 * dpr}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
|
||||
@@ -230,13 +233,15 @@ export function drawPolarGrid(
|
||||
export function drawLinearGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layout: VectorscopeLayout,
|
||||
gridColor: string,
|
||||
gridMajorColor: string,
|
||||
_gridMinorColor: string,
|
||||
labelColor: string,
|
||||
unipolar: boolean,
|
||||
dpr: number
|
||||
): void {
|
||||
const { centerX, centerY, radius } = layout
|
||||
|
||||
ctx.strokeStyle = gridColor
|
||||
ctx.strokeStyle = gridMajorColor
|
||||
ctx.lineWidth = dpr
|
||||
|
||||
const scales = [0.25, 0.5, 0.75, 1.0]
|
||||
@@ -275,7 +280,7 @@ export function drawLinearGrid(
|
||||
ctx.stroke()
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = gridColor
|
||||
ctx.fillStyle = labelColor
|
||||
ctx.font = `${10 * dpr}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
|
||||
@@ -295,7 +300,9 @@ export function drawVectorscopeGridForMode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
gridColor: string,
|
||||
gridMajorColor: string,
|
||||
gridMinorColor: string,
|
||||
labelColor: string,
|
||||
mode: VectorscopeMode,
|
||||
dpr: number = 1
|
||||
): void {
|
||||
@@ -303,19 +310,19 @@ export function drawVectorscopeGridForMode(
|
||||
|
||||
switch (mode) {
|
||||
case 'lissajous':
|
||||
drawLissajousGrid(ctx, layout, gridColor, dpr)
|
||||
drawLissajousGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, dpr)
|
||||
break
|
||||
case 'polar-unipolar':
|
||||
drawPolarGrid(ctx, layout, gridColor, true, dpr)
|
||||
drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr)
|
||||
break
|
||||
case 'polar-bipolar':
|
||||
drawPolarGrid(ctx, layout, gridColor, false, dpr)
|
||||
drawPolarGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr)
|
||||
break
|
||||
case 'linear-unipolar':
|
||||
drawLinearGrid(ctx, layout, gridColor, true, dpr)
|
||||
drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, true, dpr)
|
||||
break
|
||||
case 'linear-bipolar':
|
||||
drawLinearGrid(ctx, layout, gridColor, false, dpr)
|
||||
drawLinearGrid(ctx, layout, gridMajorColor, gridMinorColor, labelColor, false, dpr)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
+662
-354
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
|
||||
import {
|
||||
WINDOW_LOCAL_STATE_FORMAT,
|
||||
WINDOW_LOCAL_STATE_VERSION,
|
||||
type PrismWindowLocalStateV1,
|
||||
} from '../types/windowState'
|
||||
|
||||
export function createEmptyWindowLocalState(): PrismWindowLocalStateV1 {
|
||||
return {
|
||||
format: WINDOW_LOCAL_STATE_FORMAT,
|
||||
version: WINDOW_LOCAL_STATE_VERSION,
|
||||
mainAlwaysOnTop: false,
|
||||
popoutAlwaysOnTop: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWindowLocalState(raw: unknown): PrismWindowLocalStateV1 {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<PrismWindowLocalStateV1>
|
||||
: {}
|
||||
|
||||
const popoutAlwaysOnTop = typeof parsed.popoutAlwaysOnTop === 'object' && parsed.popoutAlwaysOnTop !== null
|
||||
? SCOPE_KINDS.reduce((acc, kind) => {
|
||||
if ((parsed.popoutAlwaysOnTop as Partial<Record<ScopeKind, unknown>>)[kind] === true) {
|
||||
acc[kind] = true
|
||||
}
|
||||
return acc
|
||||
}, {} as Partial<Record<ScopeKind, boolean>>)
|
||||
: {}
|
||||
|
||||
return {
|
||||
format: WINDOW_LOCAL_STATE_FORMAT,
|
||||
version: WINDOW_LOCAL_STATE_VERSION,
|
||||
mainAlwaysOnTop: parsed.mainAlwaysOnTop === true,
|
||||
popoutAlwaysOnTop,
|
||||
}
|
||||
}
|
||||
+148
-49
@@ -1,7 +1,7 @@
|
||||
import type { ScopeKind } from './scope'
|
||||
|
||||
export const THEME_FILE_FORMAT = 'prism-theme'
|
||||
export const THEME_FILE_VERSION = 1
|
||||
export const THEME_FILE_VERSION = 2
|
||||
export const THEME_LOCAL_STATE_FORMAT = 'prism-theme-local'
|
||||
export const THEME_LOCAL_STATE_VERSION = 1
|
||||
export const LEGACY_THEME_MIGRATION_VERSION = 1
|
||||
@@ -9,29 +9,107 @@ export const DEFAULT_THEME_ID = 'theme_default'
|
||||
export const DEFAULT_THEME_NAME = 'Default'
|
||||
|
||||
export type ThemeSectionName =
|
||||
| 'all'
|
||||
| 'interface'
|
||||
| 'app'
|
||||
| 'controls'
|
||||
| 'scopes'
|
||||
| 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
|
||||
export interface ThemeAppTokens {
|
||||
accent?: string
|
||||
success?: string
|
||||
warning?: string
|
||||
danger?: string
|
||||
background?: string
|
||||
surface?: string
|
||||
surfaceAlt?: string
|
||||
border?: string
|
||||
text?: string
|
||||
textMuted?: string
|
||||
}
|
||||
|
||||
export interface ThemeControlsTokens {
|
||||
surface?: string
|
||||
surfaceHover?: string
|
||||
surfaceActive?: string
|
||||
border?: string
|
||||
borderActive?: string
|
||||
text?: string
|
||||
inputSurface?: string
|
||||
inputBorder?: string
|
||||
menuSurface?: string
|
||||
menuBorder?: string
|
||||
slider?: string
|
||||
}
|
||||
|
||||
export interface ThemeScopesTokens {
|
||||
background?: string
|
||||
guides?: string
|
||||
overlaySurface?: string
|
||||
overlayText?: string
|
||||
overlayBorder?: string
|
||||
resizeHandle?: string
|
||||
}
|
||||
|
||||
export interface ThemeSpectrumTokens {
|
||||
line?: string
|
||||
sideLine?: string
|
||||
fill?: string
|
||||
heatLow?: string
|
||||
heatMid?: string
|
||||
heatHigh?: string
|
||||
}
|
||||
|
||||
export interface ThemeOscilloscopeTokens {
|
||||
line?: string
|
||||
fill?: string
|
||||
}
|
||||
|
||||
export interface ThemeVectorscopeTokens {
|
||||
trace?: string
|
||||
bandLow?: string
|
||||
bandMid?: string
|
||||
bandHigh?: string
|
||||
}
|
||||
|
||||
export interface ThemeSpectrogramTokens {
|
||||
mono?: string
|
||||
heatLow?: string
|
||||
heatMid?: string
|
||||
heatHigh?: string
|
||||
}
|
||||
|
||||
export interface ThemeVUMeterTokens {
|
||||
level?: string
|
||||
track?: string
|
||||
peak?: string
|
||||
clip?: string
|
||||
}
|
||||
|
||||
export interface ThemeLUFSMeterTokens {
|
||||
level?: string
|
||||
track?: string
|
||||
target?: string
|
||||
}
|
||||
|
||||
export interface ThemeWaveformTokens {
|
||||
line?: string
|
||||
bandLow?: string
|
||||
bandMid?: string
|
||||
bandHigh?: string
|
||||
}
|
||||
|
||||
export interface ThemeAstraTokens {
|
||||
accent?: string
|
||||
background?: string
|
||||
surface?: string
|
||||
border?: string
|
||||
text?: string
|
||||
progressTrack?: string
|
||||
progressFill?: string
|
||||
buttonSurface?: string
|
||||
buttonBorder?: string
|
||||
statusOk?: string
|
||||
statusError?: string
|
||||
}
|
||||
|
||||
export interface PrismTheme {
|
||||
@@ -40,16 +118,17 @@ export interface PrismTheme {
|
||||
credit?: string
|
||||
website?: string
|
||||
description?: string
|
||||
all: ThemeTokens
|
||||
interface: ThemeTokens
|
||||
spectrum: ThemeTokens
|
||||
oscilloscope: ThemeTokens
|
||||
vectorscope: ThemeTokens
|
||||
spectrogram: ThemeTokens
|
||||
vumeter: ThemeTokens
|
||||
lufsmeter: ThemeTokens
|
||||
waveform: ThemeTokens
|
||||
astra: ThemeTokens
|
||||
app: ThemeAppTokens
|
||||
controls: ThemeControlsTokens
|
||||
scopes: ThemeScopesTokens
|
||||
spectrum: ThemeSpectrumTokens
|
||||
oscilloscope: ThemeOscilloscopeTokens
|
||||
vectorscope: ThemeVectorscopeTokens
|
||||
spectrogram: ThemeSpectrogramTokens
|
||||
vumeter: ThemeVUMeterTokens
|
||||
lufsmeter: ThemeLUFSMeterTokens
|
||||
waveform: ThemeWaveformTokens
|
||||
astra: ThemeAstraTokens
|
||||
}
|
||||
|
||||
export interface PrismThemeLocalStateV1 {
|
||||
@@ -83,7 +162,7 @@ export interface LegacyThemeMigrationResult {
|
||||
export interface ResolvedInterfaceTheme {
|
||||
primary: string
|
||||
secondary: string
|
||||
guides: string
|
||||
border: string
|
||||
text: string
|
||||
background: string
|
||||
accent: string
|
||||
@@ -115,72 +194,92 @@ export interface ResolvedInterfaceTheme {
|
||||
controlBgActive: string
|
||||
controlBorder: string
|
||||
controlBorderActive: string
|
||||
controlText: string
|
||||
inputBg: string
|
||||
inputBgFocus: string
|
||||
inputBorder: string
|
||||
inputBorderFocus: string
|
||||
optionBg: string
|
||||
optionText: string
|
||||
sliderTrack: string
|
||||
sliderFill: string
|
||||
sliderThumb: string
|
||||
divider: string
|
||||
scopeBackground: string
|
||||
scopeGuides: string
|
||||
scopeOverlayBg: string
|
||||
scopeOverlayText: string
|
||||
scopeOverlayBorder: string
|
||||
scopeResizeHandle: string
|
||||
success: string
|
||||
warning: string
|
||||
danger: string
|
||||
}
|
||||
|
||||
export interface ResolvedSpectrumTheme {
|
||||
primary: string
|
||||
secondary: string
|
||||
line: string
|
||||
sideLine: string
|
||||
guides: string
|
||||
guidesSecondary: string
|
||||
labels: string
|
||||
background: string
|
||||
fill: string
|
||||
fillGradient: [string, string, string]
|
||||
heatColors: [string, string, string]
|
||||
}
|
||||
|
||||
export interface ResolvedOscilloscopeTheme {
|
||||
primary: string
|
||||
line: string
|
||||
guides: string
|
||||
guidesSecondary: string
|
||||
background: string
|
||||
fill: string
|
||||
}
|
||||
|
||||
export interface ResolvedVectorscopeTheme {
|
||||
primary: string
|
||||
trace: string
|
||||
guides: string
|
||||
guidesSecondary: string
|
||||
labels: string
|
||||
background: string
|
||||
lowBand: string
|
||||
midBand: string
|
||||
highBand: string
|
||||
bandLow: string
|
||||
bandMid: string
|
||||
bandHigh: string
|
||||
}
|
||||
|
||||
export interface ResolvedSpectrogramTheme {
|
||||
primary: string
|
||||
guides: string
|
||||
mono: string
|
||||
background: string
|
||||
heatColors: [string, string, string]
|
||||
}
|
||||
|
||||
export interface ResolvedVUMeterTheme {
|
||||
primary: string
|
||||
level: string
|
||||
track: string
|
||||
peak: string
|
||||
clip: string
|
||||
guides: string
|
||||
text: string
|
||||
scale: string
|
||||
labels: string
|
||||
background: string
|
||||
}
|
||||
|
||||
export interface ResolvedLUFSMeterTheme {
|
||||
primary: string
|
||||
level: string
|
||||
track: string
|
||||
target: string
|
||||
guides: string
|
||||
text: string
|
||||
scale: string
|
||||
labels: string
|
||||
background: string
|
||||
}
|
||||
|
||||
export interface ResolvedWaveformTheme {
|
||||
primary: string
|
||||
line: string
|
||||
guides: string
|
||||
guidesSecondary: string
|
||||
background: string
|
||||
lowBand: string
|
||||
midBand: string
|
||||
highBand: string
|
||||
bandLow: string
|
||||
bandMid: string
|
||||
bandHigh: string
|
||||
}
|
||||
|
||||
export interface ResolvedAstraTheme {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ScopeKind } from './scope'
|
||||
|
||||
export const WINDOW_LOCAL_STATE_FORMAT = 'prism-window-local'
|
||||
export const WINDOW_LOCAL_STATE_VERSION = 1
|
||||
|
||||
export interface PrismWindowLocalStateV1 {
|
||||
format: typeof WINDOW_LOCAL_STATE_FORMAT
|
||||
version: typeof WINDOW_LOCAL_STATE_VERSION
|
||||
mainAlwaysOnTop: boolean
|
||||
popoutAlwaysOnTop: Partial<Record<ScopeKind, boolean>>
|
||||
}
|
||||
@@ -745,8 +745,10 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer
|
||||
const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum)
|
||||
|
||||
assert.equal(options.showSideLine, true)
|
||||
assert.equal(options.secondaryLineColor, theme.spectrum.secondary)
|
||||
assert.equal(options.lineColor, theme.spectrum.primary)
|
||||
assert.equal(options.secondaryLineColor, theme.spectrum.sideLine)
|
||||
assert.equal(options.lineColor, theme.spectrum.line)
|
||||
assert.equal(options.backgroundColor, theme.spectrum.background)
|
||||
assert.equal(options.gridColor, theme.spectrum.guides)
|
||||
})
|
||||
|
||||
test('astra playback progress advances from updatedAt while playing', () => {
|
||||
@@ -904,7 +906,55 @@ test('scopeSettingsToOptions wires waveform stereo mode into analyzer options',
|
||||
|
||||
assert.equal(options.mode, 'stereo')
|
||||
assert.equal(options.multiband, true)
|
||||
assert.equal(options.lineColor, theme.waveform.primary)
|
||||
assert.equal(options.lineColor, theme.waveform.line)
|
||||
assert.equal(options.backgroundColor, theme.waveform.background)
|
||||
assert.equal(options.gridMajorColor, theme.waveform.guides)
|
||||
assert.equal(options.gridMinorColor, theme.waveform.guidesSecondary)
|
||||
})
|
||||
|
||||
test('scopeSettingsToOptions forwards shared scope background and guides to oscilloscope and vectorscope', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
const authoredTheme = createDefaultTheme()
|
||||
authoredTheme.scopes.background = 'rgb(3, 4, 5)'
|
||||
authoredTheme.scopes.guides = 'rgba(120, 130, 140, 0.2)'
|
||||
const theme = resolveTheme(authoredTheme)
|
||||
|
||||
const oscilloscope = scopeSettingsToOptions('oscilloscope', profile.scopeSettings.oscilloscope, theme.oscilloscope)
|
||||
assert.equal(oscilloscope.backgroundColor, 'rgb(3, 4, 5)')
|
||||
assert.equal(oscilloscope.gridMajorColor, theme.oscilloscope.guides)
|
||||
assert.equal(oscilloscope.gridMinorColor, theme.oscilloscope.guidesSecondary)
|
||||
|
||||
const vectorscope = scopeSettingsToOptions('vectorscope', profile.scopeSettings.vectorscope, theme.vectorscope)
|
||||
assert.equal(vectorscope.backgroundColor, 'rgb(3, 4, 5)')
|
||||
assert.equal(vectorscope.gridMajorColor, theme.vectorscope.guides)
|
||||
assert.equal(vectorscope.gridMinorColor, theme.vectorscope.guidesSecondary)
|
||||
assert.equal(vectorscope.labelColor, theme.vectorscope.labels)
|
||||
})
|
||||
|
||||
test('scopeSettingsToOptions forwards themed backgrounds and track colors to spectrogram, VU, and LUFS modules', () => {
|
||||
const profile = createDefaultProfile('Default')
|
||||
const authoredTheme = createDefaultTheme()
|
||||
authoredTheme.scopes.background = 'rgb(6, 7, 8)'
|
||||
authoredTheme.vumeter.track = 'rgb(9, 10, 11)'
|
||||
authoredTheme.lufsmeter.track = 'rgb(12, 13, 14)'
|
||||
authoredTheme.lufsmeter.target = 'rgb(15, 16, 17)'
|
||||
const theme = resolveTheme(authoredTheme)
|
||||
|
||||
const spectrogram = scopeSettingsToOptions('spectrogram', profile.scopeSettings.spectrogram, theme.spectrogram)
|
||||
assert.equal(spectrogram.backgroundColor, 'rgb(6, 7, 8)')
|
||||
|
||||
const vumeter = scopeSettingsToOptions('vumeter', profile.scopeSettings.vumeter, theme.vumeter)
|
||||
assert.equal(vumeter.backgroundColor, 'rgb(6, 7, 8)')
|
||||
assert.equal(vumeter.trackColor, 'rgb(9, 10, 11)')
|
||||
assert.equal(vumeter.scaleColor, theme.vumeter.scale)
|
||||
assert.equal(vumeter.labelColor, theme.vumeter.labels)
|
||||
|
||||
const lufsmeter = scopeSettingsToOptions('lufsmeter', profile.scopeSettings.lufsmeter, theme.lufsmeter)
|
||||
assert.equal(lufsmeter.backgroundColor, 'rgb(6, 7, 8)')
|
||||
assert.equal(lufsmeter.trackColor, 'rgb(12, 13, 14)')
|
||||
assert.equal(lufsmeter.targetColor, 'rgb(15, 16, 17)')
|
||||
assert.equal(lufsmeter.scaleColor, theme.lufsmeter.scale)
|
||||
assert.equal(lufsmeter.labelColor, theme.lufsmeter.labels)
|
||||
})
|
||||
|
||||
test('scopeSummary includes Stereo for waveform only when stereo mode is enabled', () => {
|
||||
|
||||
+125
-18
@@ -5,10 +5,13 @@ import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { FileBackedThemeLibrary } from '../src/main/themeLibrary'
|
||||
import {
|
||||
createBundledThemes,
|
||||
createDefaultTheme,
|
||||
createMigratedAccentTheme,
|
||||
parseThemeFileContent,
|
||||
resolveTheme,
|
||||
serializeThemeFile,
|
||||
themeToCssVariables,
|
||||
} from '../src/shared/themeState'
|
||||
import {
|
||||
DEFAULT_THEME_ID,
|
||||
@@ -37,37 +40,110 @@ async function createHarness(): Promise<{
|
||||
|
||||
test('theme files round-trip and keep grouped sections intact', () => {
|
||||
const theme = createDefaultTheme()
|
||||
theme.app.accent = '#4ade80'
|
||||
theme.controls.menuSurface = 'rgb(12, 18, 32)'
|
||||
theme.scopes.background = '#030712'
|
||||
theme.spectrum.heatMid = 'rgb(200, 50, 120)'
|
||||
theme.vumeter.track = '#111827'
|
||||
|
||||
const serialized = serializeThemeFile(theme)
|
||||
const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_ID, DEFAULT_THEME_NAME)
|
||||
|
||||
assert.match(serialized, /\[Theme\]/)
|
||||
assert.match(serialized, /version = 2/)
|
||||
assert.match(serialized, /\[App\]/)
|
||||
assert.match(serialized, /\[Controls\]/)
|
||||
assert.match(serialized, /\[Scopes\]/)
|
||||
|
||||
assert.equal(parsed.id, DEFAULT_THEME_ID)
|
||||
assert.equal(parsed.name, DEFAULT_THEME_NAME)
|
||||
assert.equal(parsed.app.accent, 'rgb(74, 222, 128)')
|
||||
assert.equal(parsed.controls.menuSurface, 'rgb(12, 18, 32)')
|
||||
assert.equal(parsed.scopes.background, 'rgb(3, 7, 18)')
|
||||
assert.equal(parsed.spectrum.heatMid, 'rgb(200, 50, 120)')
|
||||
assert.equal(parsed.interface.secondary, theme.interface.secondary)
|
||||
assert.equal(parsed.vumeter.track, 'rgb(17, 24, 39)')
|
||||
assert.equal(parsed.astra.background, theme.astra.background)
|
||||
})
|
||||
|
||||
test('astra theme falls back to [All] tokens until [Astra] overrides are provided', () => {
|
||||
test('resolveTheme maps grouped app, controls, and scopes tokens into UI and scope surfaces', () => {
|
||||
const theme = createDefaultTheme()
|
||||
theme.all.primary = 'rgb(255, 159, 67)'
|
||||
theme.all.background = 'rgb(5, 6, 7)'
|
||||
theme.all.text = 'rgb(240, 244, 248)'
|
||||
theme.app.accent = 'rgb(255, 159, 67)'
|
||||
theme.app.background = 'rgb(5, 6, 7)'
|
||||
theme.app.surface = 'rgb(8, 9, 10)'
|
||||
theme.app.surfaceAlt = 'rgb(11, 12, 13)'
|
||||
theme.app.text = 'rgb(240, 244, 248)'
|
||||
theme.app.textMuted = 'rgba(220, 224, 228, 0.4)'
|
||||
theme.controls.text = 'rgb(225, 229, 233)'
|
||||
theme.controls.menuSurface = 'rgb(14, 15, 16)'
|
||||
theme.controls.menuBorder = 'rgb(17, 18, 19)'
|
||||
theme.controls.inputSurface = 'rgb(20, 21, 22)'
|
||||
theme.controls.inputBorder = 'rgb(23, 24, 25)'
|
||||
theme.controls.slider = 'rgb(26, 27, 28)'
|
||||
theme.scopes.background = 'rgb(29, 30, 31)'
|
||||
theme.scopes.guides = 'rgba(100, 110, 120, 0.2)'
|
||||
theme.scopes.overlaySurface = 'rgb(32, 33, 34)'
|
||||
theme.scopes.overlayText = 'rgb(235, 236, 237)'
|
||||
theme.scopes.overlayBorder = 'rgb(35, 36, 37)'
|
||||
theme.scopes.resizeHandle = 'rgb(38, 39, 40)'
|
||||
theme.vumeter.track = 'rgb(41, 42, 43)'
|
||||
theme.lufsmeter.track = 'rgb(44, 45, 46)'
|
||||
theme.astra = {}
|
||||
|
||||
const fallbackResolved = resolveTheme(theme)
|
||||
assert.equal(fallbackResolved.astra.accent, 'rgb(255, 159, 67)')
|
||||
assert.equal(fallbackResolved.astra.background, 'rgb(5, 6, 7)')
|
||||
assert.equal(fallbackResolved.astra.text, 'rgb(240, 244, 248)')
|
||||
const resolved = resolveTheme(theme)
|
||||
assert.equal(resolved.interface.accent, 'rgb(255, 159, 67)')
|
||||
assert.equal(resolved.interface.menuBg, 'rgb(14, 15, 16)')
|
||||
assert.equal(resolved.interface.menuBorder, 'rgb(17, 18, 19)')
|
||||
assert.equal(resolved.interface.inputBg, 'rgb(20, 21, 22)')
|
||||
assert.equal(resolved.interface.inputBorder, 'rgb(23, 24, 25)')
|
||||
assert.equal(resolved.interface.controlText, 'rgb(225, 229, 233)')
|
||||
assert.equal(resolved.interface.optionBg, 'rgb(14, 15, 16)')
|
||||
assert.equal(resolved.interface.optionText, 'rgb(225, 229, 233)')
|
||||
assert.equal(resolved.interface.sliderFill, 'rgb(26, 27, 28)')
|
||||
assert.equal(resolved.interface.scopeBackground, 'rgb(29, 30, 31)')
|
||||
assert.equal(resolved.interface.scopeGuides, 'rgba(100, 110, 120, 0.2)')
|
||||
assert.equal(resolved.interface.scopeOverlayBg, 'rgb(32, 33, 34)')
|
||||
assert.equal(resolved.interface.scopeOverlayText, 'rgb(235, 236, 237)')
|
||||
assert.equal(resolved.interface.scopeOverlayBorder, 'rgb(35, 36, 37)')
|
||||
assert.equal(resolved.interface.scopeResizeHandle, 'rgb(38, 39, 40)')
|
||||
assert.equal(resolved.spectrogram.background, 'rgb(29, 30, 31)')
|
||||
assert.equal(resolved.waveform.guides, 'rgba(100, 110, 120, 0.2)')
|
||||
assert.equal(resolved.vumeter.track, 'rgb(41, 42, 43)')
|
||||
assert.equal(resolved.lufsmeter.track, 'rgb(44, 45, 46)')
|
||||
assert.equal(resolved.astra.accent, 'rgb(255, 159, 67)')
|
||||
assert.equal(resolved.astra.background, 'rgb(29, 30, 31)')
|
||||
assert.equal(resolved.astra.text, 'rgb(240, 244, 248)')
|
||||
})
|
||||
|
||||
theme.astra.primary = 'rgb(129, 140, 248)'
|
||||
theme.astra.background = 'rgba(12, 18, 32, 0.88)'
|
||||
theme.astra.text = 'rgb(248, 250, 252)'
|
||||
test('themeToCssVariables exposes grouped UI, control, and scope variables', () => {
|
||||
const theme = createDefaultTheme()
|
||||
theme.controls.menuSurface = 'rgb(11, 12, 13)'
|
||||
theme.controls.text = 'rgb(220, 221, 222)'
|
||||
theme.scopes.overlaySurface = 'rgb(14, 15, 16)'
|
||||
theme.scopes.overlayText = 'rgb(223, 224, 225)'
|
||||
theme.scopes.overlayBorder = 'rgb(17, 18, 19)'
|
||||
theme.scopes.resizeHandle = 'rgb(20, 21, 22)'
|
||||
|
||||
const overriddenResolved = resolveTheme(theme)
|
||||
assert.equal(overriddenResolved.astra.accent, 'rgb(129, 140, 248)')
|
||||
assert.match(overriddenResolved.astra.background, /^rgba\(12, 18, 32, 0\.87/)
|
||||
assert.equal(overriddenResolved.astra.text, 'rgb(248, 250, 252)')
|
||||
const variables = themeToCssVariables(resolveTheme(theme))
|
||||
assert.equal(variables['--menu-bg'], 'rgb(11, 12, 13)')
|
||||
assert.equal(variables['--option-bg'], 'rgb(11, 12, 13)')
|
||||
assert.equal(variables['--option-text'], 'rgb(220, 221, 222)')
|
||||
assert.equal(variables['--slider-fill'], resolveTheme(theme).interface.sliderFill)
|
||||
assert.equal(variables['--scope-overlay-bg'], 'rgb(14, 15, 16)')
|
||||
assert.equal(variables['--scope-overlay-text'], 'rgb(223, 224, 225)')
|
||||
assert.equal(variables['--scope-overlay-border'], 'rgb(17, 18, 19)')
|
||||
assert.equal(variables['--scope-resize-handle'], 'rgb(20, 21, 22)')
|
||||
})
|
||||
|
||||
test('bundled and migrated accent themes recolor every accent-driven scope', () => {
|
||||
const purple = createBundledThemes().find((theme) => theme.id === 'theme_purple')
|
||||
assert.ok(purple)
|
||||
assert.equal(purple.oscilloscope.line, purple.app.accent)
|
||||
assert.equal(purple.vectorscope.trace, purple.app.accent)
|
||||
|
||||
const migrated = createMigratedAccentTheme('#4ade80')
|
||||
assert.ok(migrated)
|
||||
assert.equal(migrated.oscilloscope.line, migrated.app.accent)
|
||||
assert.equal(migrated.vectorscope.trace, migrated.app.accent)
|
||||
})
|
||||
|
||||
test('library seeds default themes and template file', async () => {
|
||||
@@ -81,6 +157,14 @@ test('library seeds default themes and template file', async () => {
|
||||
const fileNames = (await readdir(harness.themesDir)).sort()
|
||||
assert.ok(fileNames.includes('Default.iro'))
|
||||
assert.ok(fileNames.includes('_TEMPLATE.iro'))
|
||||
|
||||
const defaultThemeContent = await readFile(join(harness.themesDir, 'Default.iro'), 'utf8')
|
||||
const templateContent = await readFile(join(harness.themesDir, '_TEMPLATE.iro'), 'utf8')
|
||||
assert.match(defaultThemeContent, /version = 2/)
|
||||
assert.match(defaultThemeContent, /\[App\]/)
|
||||
assert.doesNotMatch(defaultThemeContent, /\[All\]/)
|
||||
assert.match(templateContent, /\[Controls\]/)
|
||||
assert.match(templateContent, /\[Scopes\]/)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
@@ -100,14 +184,37 @@ test('importing the same embedded theme id replaces the managed theme', async ()
|
||||
assert.equal(firstSnapshot.activeThemeId, 'theme_shared')
|
||||
|
||||
theme.name = 'Shared Updated'
|
||||
theme.all.primary = 'rgb(74, 222, 128)'
|
||||
theme.app.accent = '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)')
|
||||
assert.equal(secondSnapshot.themes.theme_shared.app.accent, 'rgb(74, 222, 128)')
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('library refreshes shipped bundled themes when their definitions change', async () => {
|
||||
const harness = await createHarness()
|
||||
|
||||
try {
|
||||
await harness.library.getSnapshot()
|
||||
|
||||
const stalePurple = createDefaultTheme()
|
||||
stalePurple.id = 'theme_purple'
|
||||
stalePurple.name = 'Purple'
|
||||
stalePurple.app.accent = 'rgb(167, 139, 250)'
|
||||
stalePurple.oscilloscope.line = 'rgb(56, 189, 248)'
|
||||
stalePurple.vectorscope.trace = 'rgb(56, 189, 248)'
|
||||
|
||||
await writeFile(join(harness.themesDir, 'Purple.iro'), serializeThemeFile(stalePurple), 'utf8')
|
||||
|
||||
const snapshot = await harness.library.reloadThemes()
|
||||
assert.equal(snapshot.themes.theme_purple.oscilloscope.line, snapshot.themes.theme_purple.app.accent)
|
||||
assert.equal(snapshot.themes.theme_purple.vectorscope.trace, snapshot.themes.theme_purple.app.accent)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { FileBackedWindowStateStore } from '../src/main/windowStateStore'
|
||||
import {
|
||||
WINDOW_LOCAL_STATE_FORMAT,
|
||||
WINDOW_LOCAL_STATE_VERSION,
|
||||
} from '../src/types/windowState'
|
||||
|
||||
async function createHarness(): Promise<{
|
||||
cleanup: () => Promise<void>
|
||||
localStatePath: string
|
||||
rootDir: string
|
||||
store: FileBackedWindowStateStore
|
||||
}> {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'prism-window-state-'))
|
||||
const localStatePath = join(rootDir, 'userData', 'window-state.json')
|
||||
|
||||
return {
|
||||
cleanup: () => rm(rootDir, { recursive: true, force: true }),
|
||||
localStatePath,
|
||||
rootDir,
|
||||
store: new FileBackedWindowStateStore(localStatePath),
|
||||
}
|
||||
}
|
||||
|
||||
test('window state defaults to unpinned when no local state file exists', async () => {
|
||||
const harness = await createHarness()
|
||||
|
||||
try {
|
||||
await harness.store.initialize()
|
||||
|
||||
assert.equal(harness.store.getMainAlwaysOnTop(), false)
|
||||
assert.equal(harness.store.getPopoutAlwaysOnTop('spectrum'), false)
|
||||
assert.equal(harness.store.getPopoutAlwaysOnTop('waveform'), false)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('main window always-on-top persists immediately and survives reload', async () => {
|
||||
const harness = await createHarness()
|
||||
|
||||
try {
|
||||
await harness.store.initialize()
|
||||
await harness.store.setMainAlwaysOnTop(true)
|
||||
|
||||
const saved = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as {
|
||||
format: string
|
||||
version: number
|
||||
mainAlwaysOnTop: boolean
|
||||
}
|
||||
assert.equal(saved.format, WINDOW_LOCAL_STATE_FORMAT)
|
||||
assert.equal(saved.version, WINDOW_LOCAL_STATE_VERSION)
|
||||
assert.equal(saved.mainAlwaysOnTop, true)
|
||||
|
||||
const reloaded = new FileBackedWindowStateStore(harness.localStatePath)
|
||||
await reloaded.initialize()
|
||||
assert.equal(reloaded.getMainAlwaysOnTop(), true)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('popout always-on-top persists independently per scope kind', async () => {
|
||||
const harness = await createHarness()
|
||||
|
||||
try {
|
||||
await harness.store.initialize()
|
||||
await harness.store.setMainAlwaysOnTop(true)
|
||||
await harness.store.setPopoutAlwaysOnTop('spectrum', true)
|
||||
|
||||
const reloaded = new FileBackedWindowStateStore(harness.localStatePath)
|
||||
await reloaded.initialize()
|
||||
|
||||
assert.equal(reloaded.getMainAlwaysOnTop(), true)
|
||||
assert.equal(reloaded.getPopoutAlwaysOnTop('spectrum'), true)
|
||||
assert.equal(reloaded.getPopoutAlwaysOnTop('waveform'), false)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('turning a popout pin back off clears its saved local state without affecting others', async () => {
|
||||
const harness = await createHarness()
|
||||
|
||||
try {
|
||||
await harness.store.initialize()
|
||||
await harness.store.setPopoutAlwaysOnTop('spectrum', true)
|
||||
await harness.store.setPopoutAlwaysOnTop('waveform', true)
|
||||
await harness.store.setPopoutAlwaysOnTop('spectrum', false)
|
||||
|
||||
const saved = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as {
|
||||
popoutAlwaysOnTop: Record<string, boolean | undefined>
|
||||
}
|
||||
assert.equal(saved.popoutAlwaysOnTop.spectrum, undefined)
|
||||
assert.equal(saved.popoutAlwaysOnTop.waveform, true)
|
||||
|
||||
const reloaded = new FileBackedWindowStateStore(harness.localStatePath)
|
||||
await reloaded.initialize()
|
||||
assert.equal(reloaded.getPopoutAlwaysOnTop('spectrum'), false)
|
||||
assert.equal(reloaded.getPopoutAlwaysOnTop('waveform'), true)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('invalid saved window state normalizes to supported keys and boolean values', async () => {
|
||||
const harness = await createHarness()
|
||||
|
||||
try {
|
||||
await mkdir(dirname(harness.localStatePath), { recursive: true })
|
||||
await writeFile(harness.localStatePath, `${JSON.stringify({
|
||||
format: 'unexpected',
|
||||
version: 99,
|
||||
mainAlwaysOnTop: true,
|
||||
popoutAlwaysOnTop: {
|
||||
spectrum: true,
|
||||
waveform: 'yes',
|
||||
invalid_scope: true,
|
||||
},
|
||||
}, null, 2)}\n`, 'utf8')
|
||||
|
||||
await harness.store.initialize()
|
||||
|
||||
assert.equal(harness.store.getMainAlwaysOnTop(), true)
|
||||
assert.equal(harness.store.getPopoutAlwaysOnTop('spectrum'), true)
|
||||
assert.equal(harness.store.getPopoutAlwaysOnTop('waveform'), false)
|
||||
assert.equal(harness.store.getPopoutAlwaysOnTop('oscilloscope'), false)
|
||||
} finally {
|
||||
await harness.cleanup()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user