remove iro file association, show theme descriptions

This commit is contained in:
Boof2015
2026-04-18 03:08:40 -04:00
parent c7142d447a
commit 8ceaafa6c8
12 changed files with 91 additions and 129 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ Save your entire layout — scope arrangement, visibility, sizes, per-scope sett
## Themes
The whole interface is themeable through `.iro` files. Themes control everything — spectrum heatmap gradients, oscilloscope trace color, vectorscope bands, all of it. Build your own or import someone else's.
The whole interface is themeable through editable `.iro` files. Themes control everything — spectrum heatmap gradients, oscilloscope trace color, vectorscope bands, all of it. Build your own in an editor, drop it into the `Prism Themes` folder, or import it from Prism.
<!-- ![Prism themes](assets/themes.png) -->
-6
View File
@@ -72,12 +72,6 @@
"name": "Prism Profile",
"description": "Prism shareable profile",
"role": "Editor"
},
{
"ext": "iro",
"name": "Prism Theme",
"description": "Prism shareable theme",
"role": "Editor"
}
],
"files": [
-73
View File
@@ -59,7 +59,6 @@ let nowPlayingConfigBoundsTimer: ReturnType<typeof setTimeout> | null = null
const windowSettingsHeights = new Map<number, number>()
const windowSettingsBottomAnchors = new Map<number, number>()
const pendingProfileOpenPaths: string[] = []
const pendingThemeOpenPaths: string[] = []
let profileLibrary: FileBackedProfileLibrary | null = null
let themeLibrary: FileBackedThemeLibrary | null = null
@@ -162,13 +161,6 @@ function getNowPlayingManager(): NowPlayingManager {
return nowPlayingManager
}
function broadcastThemeSnapshot(snapshot: ThemeLibrarySnapshot): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) continue
window.webContents.send('themes:external-activated', snapshot)
}
}
function queueProfileOpenPath(filePath: string): void {
if (extname(filePath).toLowerCase() !== '.prsm') return
@@ -184,33 +176,12 @@ function queueProfileOpenPaths(paths: string[]): void {
}
}
function queueThemeOpenPath(filePath: string): void {
if (extname(filePath).toLowerCase() !== '.iro') return
const resolvedPath = resolve(filePath)
if (!pendingThemeOpenPaths.includes(resolvedPath)) {
pendingThemeOpenPaths.push(resolvedPath)
}
}
function queueThemeOpenPaths(paths: string[]): void {
for (const filePath of paths) {
queueThemeOpenPath(filePath)
}
}
function extractProfilePathsFromArgv(argv: string[]): string[] {
return argv
.filter((value) => extname(value).toLowerCase() === '.prsm')
.map((value) => resolve(value))
}
function extractThemePathsFromArgv(argv: string[]): string[] {
return argv
.filter((value) => extname(value).toLowerCase() === '.iro')
.map((value) => resolve(value))
}
function focusMainWindow(): void {
if (!mainWindow) return
if (mainWindow.isMinimized()) {
@@ -221,12 +192,6 @@ function focusMainWindow(): void {
raiseMainWindowAboveNormalPopouts()
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
: fallback
}
function normalizeExternalHttpUrl(raw: string): string | null {
if (typeof raw !== 'string' || !raw.trim()) {
return null
@@ -257,32 +222,6 @@ async function processPendingProfileOpenPaths(): Promise<void> {
}
}
async function processPendingThemeOpenPaths(): Promise<void> {
if (pendingThemeOpenPaths.length === 0) return
const paths = [...pendingThemeOpenPaths]
pendingThemeOpenPaths.length = 0
let latestSnapshot: ThemeLibrarySnapshot | null = null
for (const filePath of paths) {
try {
latestSnapshot = await getThemeLibrary().importThemeFromPath(filePath)
} catch (error) {
dialog.showErrorBox(
'Could Not Open Theme',
getErrorMessage(error, `Prism could not open ${filePath}.`),
)
}
}
if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return
applyNativeThemeSnapshot(latestSnapshot)
focusMainWindow()
broadcastThemeSnapshot(latestSnapshot)
}
function applyNativeThemeSnapshot(snapshot: ThemeLibrarySnapshot): void {
const activeTheme = snapshot.activeThemeId
? snapshot.themes[snapshot.activeThemeId] ?? null
@@ -1386,28 +1325,24 @@ function setupIPC(): void {
ipcMain.handle('themes:load', async (_event, id: string) => {
const snapshot = await getThemeLibrary().loadTheme(id)
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:rename', async (_event, id: string, name: string) => {
const snapshot = await getThemeLibrary().renameTheme(id, name)
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:delete', async (_event, id: string) => {
const snapshot = await getThemeLibrary().deleteTheme(id)
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:reload', async () => {
const snapshot = await getThemeLibrary().reloadThemes()
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
@@ -1432,7 +1367,6 @@ function setupIPC(): void {
const snapshot = await getThemeLibrary().importThemeFromPath(result.filePaths[0])
applyNativeThemeSnapshot(snapshot)
broadcastThemeSnapshot(snapshot)
return snapshot
})
@@ -1447,7 +1381,6 @@ function setupIPC(): void {
ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => {
const migration = await getThemeLibrary().migrateLegacyTheme(payload)
applyNativeThemeSnapshot(migration.snapshot)
broadcastThemeSnapshot(migration.snapshot)
return migration
})
@@ -1614,28 +1547,22 @@ if (!hasSingleInstanceLock) {
await syncNativeThemeAppearance()
createMainWindow()
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
queueThemeOpenPaths(extractThemePathsFromArgv(process.argv))
void processPendingProfileOpenPaths()
void processPendingThemeOpenPaths()
})
app.on('open-file', (event, filePath) => {
event.preventDefault()
queueProfileOpenPath(filePath)
queueThemeOpenPath(filePath)
if (app.isReady()) {
void processPendingProfileOpenPaths()
void processPendingThemeOpenPaths()
}
})
app.on('second-instance', (_event, argv) => {
queueProfileOpenPaths(extractProfilePathsFromArgv(argv))
queueThemeOpenPaths(extractThemePathsFromArgv(argv))
if (app.isReady()) {
focusMainWindow()
void processPendingProfileOpenPaths()
void processPendingThemeOpenPaths()
}
})
}
-5
View File
@@ -169,11 +169,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('profiles:external-activated', handler)
return () => ipcRenderer.removeListener('profiles:external-activated', handler)
},
onExternalThemeActivated: (callback: (snapshot: ThemeLibrarySnapshot) => void) => {
const handler = (_event: Electron.IpcRendererEvent, snapshot: ThemeLibrarySnapshot): void => callback(snapshot)
ipcRenderer.on('themes:external-activated', handler)
return () => ipcRenderer.removeListener('themes:external-activated', handler)
},
onScopePopoutReady: (callback: (kind: ScopeKind) => void) => {
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind)
ipcRenderer.on('scope-popout:ready', handler)
-6
View File
@@ -27,7 +27,6 @@ export default function App(): JSX.Element {
const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder)
const updateMainWindowBounds = useSettingsStore((s) => s.updateMainWindowBounds)
const initializeThemes = useThemeStore((s) => s.initializeThemes)
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
const initializeNowPlaying = useNowPlayingStore((s) => s.initialize)
const setNowPlayingConsumerActive = useNowPlayingStore((s) => s.setConsumerActive)
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
@@ -64,9 +63,6 @@ export default function App(): JSX.Element {
const unsubscribeProfile = window.electronAPI.onExternalProfileActivated((snapshot) => {
applyExternalProfileSnapshot(snapshot)
})
const unsubscribeTheme = window.electronAPI.onExternalThemeActivated((snapshot) => {
applyExternalThemeSnapshot(snapshot)
})
const unsubscribeBounds = window.electronAPI.onMainWindowBoundsChanged((bounds) => {
updateMainWindowBounds(bounds)
})
@@ -124,14 +120,12 @@ export default function App(): JSX.Element {
return () => {
isDisposed = true
unsubscribeProfile()
unsubscribeTheme()
unsubscribeBounds()
unsubscribeExternalOpenRequested()
unsubscribeCloseRequested()
}
}, [
applyExternalProfileSnapshot,
applyExternalThemeSnapshot,
guardProfileTransition,
importProfileFromPath,
initializeProfiles,
+31 -18
View File
@@ -47,38 +47,43 @@ function getErrorMessage(error: unknown, fallback: string): string {
type ThemeCreditSource = {
credit?: string
website?: string
description?: string
} | null | undefined
export function resolveThemeCreditDetails(theme: ThemeCreditSource): {
credit: string | null
url: string | null
description: string | null
} {
const credit = typeof theme?.credit === 'string' && theme.credit.trim()
? theme.credit.trim()
: null
if (!credit) {
return { credit: null, url: null }
return { credit: null, url: null, description: null }
}
const website = typeof theme?.website === 'string' && theme.website.trim()
? theme.website.trim()
: null
const description = typeof theme?.description === 'string' && theme.description.trim()
? theme.description.trim()
: null
if (!website) {
return { credit, url: null }
return { credit, url: null, description }
}
try {
const parsed = new URL(website)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return { credit, url: parsed.toString() }
return { credit, url: parsed.toString(), description }
}
} catch {
// Invalid URLs fall back to plain credit text.
}
return { credit, url: null }
return { credit, url: null, description }
}
export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element {
@@ -368,20 +373,28 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<div className="bottom-bar__section-header">
<div className="bottom-bar__section-title">Theme</div>
{themeCredit.credit ? (
themeCredit.url ? (
<a
className="bottom-bar__theme-credit bottom-bar__theme-credit--link"
href={themeCredit.url}
onClick={(event) => {
event.preventDefault()
void handleOpenThemeWebsite(themeCredit.url!)
}}
>
By {themeCredit.credit}
</a>
) : (
<span className="bottom-bar__theme-credit">By {themeCredit.credit}</span>
)
<span className="bottom-bar__theme-metadata">
{themeCredit.url ? (
<a
className="bottom-bar__theme-credit bottom-bar__theme-credit--link"
href={themeCredit.url}
onClick={(event) => {
event.preventDefault()
void handleOpenThemeWebsite(themeCredit.url!)
}}
>
By {themeCredit.credit}
</a>
) : (
<span className="bottom-bar__theme-credit">By {themeCredit.credit}</span>
)}
{themeCredit.description ? (
<span className="bottom-bar__theme-description">
<span className="bottom-bar__theme-separator" aria-hidden="true">·</span>
<span>{themeCredit.description}</span>
</span>
) : null}
</span>
) : null}
</div>
<div className="bottom-bar__section-body">
@@ -217,7 +217,6 @@ function ProviderIcon({ providerId }: { providerId: NowPlayingProviderId }): JSX
export default function NowPlayingConfigWindow(): JSX.Element {
const initializeThemes = useThemeStore((s) => s.initializeThemes)
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
const initializeNowPlaying = useNowPlayingStore((s) => s.initialize)
const nowPlayingState = useNowPlayingStore((s) => s.nowPlayingState)
const saveProviderConfig = useNowPlayingStore((s) => s.saveProviderConfig)
@@ -245,17 +244,11 @@ export default function NowPlayingConfigWindow(): JSX.Element {
})
})
const unsubscribeTheme = window.electronAPI.onExternalThemeActivated((snapshot) => {
applyExternalThemeSnapshot(snapshot)
})
return () => {
disposed = true
unsubscribeTheme()
window.electronAPI.stopWindowMove()
}
}, [
applyExternalThemeSnapshot,
initializeNowPlaying,
initializeThemes,
showBanner,
-1
View File
@@ -105,7 +105,6 @@ declare global {
onProfileMenuShowFolder: (callback: () => void) => () => void
onExternalProfileOpenRequested: (callback: (path: string) => void) => () => void
onExternalProfileActivated: (callback: (snapshot: ProfileLibrarySnapshot) => void) => () => void
onExternalThemeActivated: (callback: (snapshot: ThemeLibrarySnapshot) => void) => () => void
onScopePopoutReady: (callback: (kind: ScopeKind) => void) => () => void
onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => () => void
onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => () => void
-5
View File
@@ -20,7 +20,6 @@ interface ThemeState {
activeTheme: PrismResolvedTheme
accent: string
initializeThemes: () => Promise<void>
applyExternalThemeSnapshot: (snapshot: ThemeLibrarySnapshot) => void
loadTheme: (id: string) => Promise<void>
renameTheme: (id: string, name: string) => Promise<void>
deleteTheme: (id: string) => Promise<void>
@@ -104,10 +103,6 @@ export const useThemeStore = create<ThemeState>((set) => ({
applyThemeSnapshot(set, snapshot)
},
applyExternalThemeSnapshot: (snapshot) => {
applyThemeSnapshot(set, snapshot)
},
loadTheme: async (id: string) => {
if (!canUseElectronAPI()) return
const snapshot = await window.electronAPI.loadTheme(id)
+15 -1
View File
@@ -1604,7 +1604,9 @@ select {
gap: 6px;
}
.bottom-bar__theme-credit {
.bottom-bar__theme-metadata {
min-width: 0;
margin-left: auto;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.4;
@@ -1614,6 +1616,18 @@ select {
text-align: right;
}
.bottom-bar__theme-credit {
color: inherit;
}
.bottom-bar__theme-description {
color: var(--text-tertiary);
}
.bottom-bar__theme-separator {
margin: 0 4px;
}
.bottom-bar__theme-credit--link {
color: var(--text-secondary);
text-decoration: none;
+40 -5
View File
@@ -2113,33 +2113,53 @@ test('resolveNativeThemeSource follows the active theme brightness for native UI
assert.equal(resolveNativeThemeSource(lightTheme), 'light')
})
test('resolveThemeCreditDetails enables links only for valid http and https theme websites', () => {
test('resolveThemeCreditDetails normalizes descriptions and enables links only for valid http and https theme websites', () => {
assert.deepEqual(
resolveThemeCreditDetails({ credit: 'Night Shift', website: 'https://themes.example/night' }),
resolveThemeCreditDetails({
credit: 'Night Shift',
website: 'https://themes.example/night',
description: ' Soft neon palette ',
}),
{
credit: 'Night Shift',
url: 'https://themes.example/night',
description: 'Soft neon palette',
},
)
assert.deepEqual(
resolveThemeCreditDetails({ credit: 'Night Shift', website: 'http://themes.example/night' }),
resolveThemeCreditDetails({
credit: 'Night Shift',
website: 'http://themes.example/night',
description: 'Soft neon palette',
}),
{
credit: 'Night Shift',
url: 'http://themes.example/night',
description: 'Soft neon palette',
},
)
assert.deepEqual(
resolveThemeCreditDetails({ credit: 'Night Shift', website: 'ftp://themes.example/night' }),
resolveThemeCreditDetails({
credit: 'Night Shift',
website: 'ftp://themes.example/night',
description: 'Soft neon palette',
}),
{
credit: 'Night Shift',
url: null,
description: 'Soft neon palette',
},
)
assert.deepEqual(
resolveThemeCreditDetails({ credit: 'Night Shift', website: 'not a url' }),
resolveThemeCreditDetails({
credit: 'Night Shift',
website: 'not a url',
description: 'Soft neon palette',
}),
{
credit: 'Night Shift',
url: null,
description: 'Soft neon palette',
},
)
assert.deepEqual(
@@ -2147,6 +2167,15 @@ test('resolveThemeCreditDetails enables links only for valid http and https them
{
credit: null,
url: null,
description: null,
},
)
assert.deepEqual(
resolveThemeCreditDetails({ description: 'Soft neon palette' }),
{
credit: null,
url: null,
description: null,
},
)
})
@@ -2157,11 +2186,17 @@ test('BottomBar theme section renders compact credit metadata and opens valid li
assert.match(componentSource, /const themeCredit = resolveThemeCreditDetails\(activeTheme\)/)
assert.match(componentSource, /window\.electronAPI\.openExternalUrl\(url\)/)
assert.match(componentSource, /bottom-bar__theme-metadata/)
assert.match(componentSource, /By \{themeCredit\.credit\}/)
assert.match(componentSource, /themeCredit\.description \?/)
assert.match(componentSource, /bottom-bar__theme-description/)
assert.match(componentSource, /bottom-bar__theme-separator/)
assert.match(componentSource, /bottom-bar__section-header/)
assert.match(componentSource, /bottom-bar__theme-credit--link/)
assert.match(stylesSource, /\.bottom-bar__section--theme \{[\s\S]*min-width: 420px;/)
assert.match(stylesSource, /\.bottom-bar__section-header \{/)
assert.match(stylesSource, /\.bottom-bar__theme-metadata \{/)
assert.match(stylesSource, /\.bottom-bar__theme-description \{/)
assert.match(stylesSource, /\.bottom-bar__theme-credit--link \{/)
})
+4 -1
View File
@@ -69,18 +69,21 @@ test('theme files round-trip and keep grouped sections intact', () => {
assert.equal(parsed.nowPlaying.background, theme.nowPlaying.background)
})
test('theme files preserve optional credit and website metadata', () => {
test('theme files preserve optional credit, website, and description metadata', () => {
const theme = createDefaultTheme()
theme.credit = 'Night Shift'
theme.website = 'https://themes.example/night-shift'
theme.description = 'Soft neon palette for late sessions'
const serialized = serializeThemeFile(theme)
const parsed = parseThemeFileContent(serialized, DEFAULT_THEME_NAME)
assert.match(serialized, /credit = Night Shift/)
assert.match(serialized, /website = https:\/\/themes\.example\/night-shift/)
assert.match(serialized, /description = Soft neon palette for late sessions/)
assert.equal(parsed.credit, 'Night Shift')
assert.equal(parsed.website, 'https://themes.example/night-shift')
assert.equal(parsed.description, 'Soft neon palette for late sessions')
})
test('parseThemeFileContent maps legacy Astra sections into now playing tokens', () => {