mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
multiple fixes to profile saving and windows specific UI bugs
This commit is contained in:
+202
-39
@@ -10,13 +10,15 @@ import type {
|
||||
WindowBounds,
|
||||
} from '../types/popout'
|
||||
import type { ProfileMenuRequest } from '../types/profileMenu'
|
||||
import type { LegacyProfileMigrationPayload, Profile, ProfileLibrarySnapshot } from '../types/profile'
|
||||
import type { LegacyProfileMigrationPayload, Profile } from '../types/profile'
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
|
||||
import type {
|
||||
LegacyThemeMigrationPayload,
|
||||
ThemeLibrarySnapshot,
|
||||
} from '../types/theme'
|
||||
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
|
||||
import { normalizeProfile } from '../shared/profileState'
|
||||
import { calculateResizedWindowBounds } from '../shared/windowResize'
|
||||
import { FileBackedProfileLibrary } from './profileLibrary'
|
||||
import { FileBackedThemeLibrary } from './themeLibrary'
|
||||
|
||||
@@ -24,7 +26,15 @@ let mainWindow: BrowserWindow | null = null
|
||||
let moveInterval: ReturnType<typeof setInterval> | null = null
|
||||
let moveStartCursor: { x: number; y: number } | null = null
|
||||
let moveStartPosition: number[] | null = null
|
||||
let resizeInterval: ReturnType<typeof setInterval> | null = null
|
||||
let resizeWindow: BrowserWindow | null = null
|
||||
let resizeStartCursor: { x: number; y: number } | null = null
|
||||
let resizeStartBounds: WindowBounds | null = null
|
||||
let resizeEdge: ResizeDirection | null = null
|
||||
let mainWindowBoundsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let mainRendererReady = false
|
||||
let allowMainWindowClose = false
|
||||
let mainWindowClosePending = false
|
||||
|
||||
const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
|
||||
const scopePopoutCloseAllowed = new Set<ScopeKind>()
|
||||
@@ -133,27 +143,15 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
async function processPendingProfileOpenPaths(): Promise<void> {
|
||||
if (pendingProfileOpenPaths.length === 0) return
|
||||
if (!mainRendererReady || !mainWindow || mainWindow.isDestroyed()) return
|
||||
|
||||
const paths = [...pendingProfileOpenPaths]
|
||||
pendingProfileOpenPaths.length = 0
|
||||
|
||||
let latestSnapshot: ProfileLibrarySnapshot | null = null
|
||||
focusMainWindow()
|
||||
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
latestSnapshot = await getProfileLibrary().importProfileFromPath(filePath)
|
||||
} catch (error) {
|
||||
dialog.showErrorBox(
|
||||
'Could Not Open Profile',
|
||||
getErrorMessage(error, `Prism could not open ${filePath}.`),
|
||||
)
|
||||
}
|
||||
mainWindow.webContents.send('profiles:open-requested', filePath)
|
||||
}
|
||||
|
||||
if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return
|
||||
|
||||
focusMainWindow()
|
||||
mainWindow.webContents.send('profiles:external-activated', latestSnapshot)
|
||||
}
|
||||
|
||||
async function processPendingThemeOpenPaths(): Promise<void> {
|
||||
@@ -182,7 +180,7 @@ async function processPendingThemeOpenPaths(): Promise<void> {
|
||||
}
|
||||
|
||||
function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
|
||||
if (!isMainRendererWindow(window)) return
|
||||
if (!isMainRendererWindow(window) || !mainRendererReady) return
|
||||
|
||||
if (mainWindowBoundsTimer) {
|
||||
clearTimeout(mainWindowBoundsTimer)
|
||||
@@ -190,8 +188,8 @@ function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
|
||||
|
||||
mainWindowBoundsTimer = setTimeout(() => {
|
||||
mainWindowBoundsTimer = null
|
||||
if (window.isDestroyed()) return
|
||||
void getProfileLibrary().updateActiveProfileWindowBounds(toLogicalBounds(window))
|
||||
if (window.isDestroyed() || window.webContents.isDestroyed()) return
|
||||
window.webContents.send('window:bounds-changed', toLogicalBounds(window))
|
||||
}, 80)
|
||||
}
|
||||
|
||||
@@ -310,6 +308,49 @@ function sendToRenderer(sender: WebContents, channel: string, ...args: unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
function isResizeDirection(value: unknown): value is ResizeDirection {
|
||||
return typeof value === 'string' && RESIZE_DIRECTIONS.includes(value as ResizeDirection)
|
||||
}
|
||||
|
||||
function stopWindowMoveController(): void {
|
||||
if (moveInterval) {
|
||||
clearInterval(moveInterval)
|
||||
moveInterval = null
|
||||
}
|
||||
moveStartCursor = null
|
||||
moveStartPosition = null
|
||||
}
|
||||
|
||||
function stopWindowResizeController(): void {
|
||||
if (resizeInterval) {
|
||||
clearInterval(resizeInterval)
|
||||
resizeInterval = null
|
||||
}
|
||||
resizeWindow = null
|
||||
resizeStartCursor = null
|
||||
resizeStartBounds = null
|
||||
resizeEdge = null
|
||||
}
|
||||
|
||||
function getFramelessWindowChromeOptions(): Pick<
|
||||
BrowserWindowConstructorOptions,
|
||||
'frame' | 'transparent' | 'backgroundColor' | 'roundedCorners' | 'hasShadow' | 'thickFrame' | 'backgroundMaterial'
|
||||
> {
|
||||
return {
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#000000',
|
||||
roundedCorners: false,
|
||||
hasShadow: false,
|
||||
...(process.platform === 'win32'
|
||||
? {
|
||||
thickFrame: false,
|
||||
backgroundMaterial: 'none',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProfileMenuRequest(raw: unknown): ProfileMenuRequest | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null
|
||||
|
||||
@@ -438,11 +479,7 @@ function loadRendererTarget(window: BrowserWindow, query: Record<string, string>
|
||||
function createMainWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
...WINDOW_DEFAULTS,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#000000',
|
||||
roundedCorners: false,
|
||||
hasShadow: false,
|
||||
...getFramelessWindowChromeOptions(),
|
||||
alwaysOnTop: true,
|
||||
autoHideMenuBar: true,
|
||||
resizable: true,
|
||||
@@ -458,7 +495,27 @@ function createMainWindow(): void {
|
||||
},
|
||||
})
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (allowMainWindowClose || !mainRendererReady || mainWindow?.webContents.isDestroyed()) {
|
||||
allowMainWindowClose = false
|
||||
mainWindowClosePending = false
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (mainWindowClosePending) {
|
||||
return
|
||||
}
|
||||
|
||||
mainWindowClosePending = true
|
||||
mainWindow?.webContents.send('window:close-requested')
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
if (resizeWindow === mainWindow) {
|
||||
stopWindowResizeController()
|
||||
}
|
||||
stopWindowMoveController()
|
||||
if (mainWindowBoundsTimer) {
|
||||
clearTimeout(mainWindowBoundsTimer)
|
||||
mainWindowBoundsTimer = null
|
||||
@@ -467,6 +524,9 @@ function createMainWindow(): void {
|
||||
windowSettingsHeights.delete(mainWindow.id)
|
||||
windowSettingsBottomAnchors.delete(mainWindow.id)
|
||||
}
|
||||
mainRendererReady = false
|
||||
allowMainWindowClose = false
|
||||
mainWindowClosePending = false
|
||||
mainWindow = null
|
||||
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
@@ -494,7 +554,7 @@ function setAllWindowsAlwaysOnTop(next: boolean): void {
|
||||
}
|
||||
|
||||
function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
|
||||
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed() || !mainRendererReady) return
|
||||
|
||||
const existingTimer = popoutBoundsTimers.get(kind)
|
||||
if (existingTimer) {
|
||||
@@ -506,7 +566,6 @@ function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
|
||||
const bounds = toLogicalBounds(window)
|
||||
void getProfileLibrary().updateActiveProfilePopoutBounds(kind, bounds)
|
||||
mainWindow.webContents.send('scope-popout:bounds-changed', kind, bounds)
|
||||
}, 80)
|
||||
|
||||
@@ -557,11 +616,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
||||
height: bounds.height,
|
||||
minWidth: POPOUT_DEFAULTS.minWidth,
|
||||
minHeight: POPOUT_DEFAULTS.minHeight,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#000000',
|
||||
roundedCorners: false,
|
||||
hasShadow: false,
|
||||
...getFramelessWindowChromeOptions(),
|
||||
resizable: true,
|
||||
fullscreenable: false,
|
||||
maximizable: false,
|
||||
@@ -600,6 +655,9 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
||||
})
|
||||
|
||||
popoutWindow.on('closed', () => {
|
||||
if (resizeWindow === popoutWindow) {
|
||||
stopWindowResizeController()
|
||||
}
|
||||
windowSettingsHeights.delete(popoutWindow.id)
|
||||
windowSettingsBottomAnchors.delete(popoutWindow.id)
|
||||
scopePopoutWindows.delete(kind)
|
||||
@@ -704,11 +762,12 @@ function setupIPC(): void {
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow) return
|
||||
|
||||
stopWindowResizeController()
|
||||
stopWindowMoveController()
|
||||
const cursor = screen.getCursorScreenPoint()
|
||||
moveStartCursor = { x: cursor.x, y: cursor.y }
|
||||
moveStartPosition = targetWindow.getPosition()
|
||||
|
||||
if (moveInterval) clearInterval(moveInterval)
|
||||
moveInterval = setInterval(() => {
|
||||
if (!targetWindow || targetWindow.isDestroyed() || !moveStartCursor || !moveStartPosition) return
|
||||
const current = screen.getCursorScreenPoint()
|
||||
@@ -719,25 +778,78 @@ function setupIPC(): void {
|
||||
})
|
||||
|
||||
ipcMain.on('window:stop-move', () => {
|
||||
if (moveInterval) {
|
||||
clearInterval(moveInterval)
|
||||
moveInterval = null
|
||||
}
|
||||
moveStartCursor = null
|
||||
moveStartPosition = null
|
||||
stopWindowMoveController()
|
||||
})
|
||||
|
||||
ipcMain.on('window:start-resize', (event, rawEdge: unknown) => {
|
||||
if (process.platform !== 'win32' || !isResizeDirection(rawEdge)) return
|
||||
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow || targetWindow.isDestroyed()) return
|
||||
|
||||
stopWindowMoveController()
|
||||
stopWindowResizeController()
|
||||
|
||||
resizeWindow = targetWindow
|
||||
resizeEdge = rawEdge
|
||||
resizeStartCursor = screen.getCursorScreenPoint()
|
||||
resizeStartBounds = targetWindow.getBounds()
|
||||
|
||||
resizeInterval = setInterval(() => {
|
||||
if (
|
||||
!resizeWindow
|
||||
|| resizeWindow.isDestroyed()
|
||||
|| !resizeStartCursor
|
||||
|| !resizeStartBounds
|
||||
|| !resizeEdge
|
||||
) {
|
||||
stopWindowResizeController()
|
||||
return
|
||||
}
|
||||
|
||||
const currentCursor = screen.getCursorScreenPoint()
|
||||
const [minWidth, minHeight] = resizeWindow.getMinimumSize()
|
||||
const nextBounds = calculateResizedWindowBounds({
|
||||
edge: resizeEdge,
|
||||
startBounds: resizeStartBounds,
|
||||
startCursor: resizeStartCursor,
|
||||
cursor: currentCursor,
|
||||
minWidth,
|
||||
minHeight,
|
||||
})
|
||||
|
||||
resizeWindow.setBounds(nextBounds)
|
||||
}, 16)
|
||||
})
|
||||
|
||||
ipcMain.on('window:stop-resize', () => {
|
||||
stopWindowResizeController()
|
||||
})
|
||||
|
||||
ipcMain.on('window:close', (event) => {
|
||||
getWindowFromSender(event.sender)?.close()
|
||||
})
|
||||
|
||||
ipcMain.on('window:close-response', (event, shouldClose: boolean) => {
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
|
||||
|
||||
mainWindowClosePending = false
|
||||
if (!shouldClose) {
|
||||
return
|
||||
}
|
||||
|
||||
allowMainWindowClose = true
|
||||
targetWindow.close()
|
||||
})
|
||||
|
||||
ipcMain.on('window:toggle-always-on-top', (event) => {
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow) return
|
||||
|
||||
const current = targetWindow.isAlwaysOnTop()
|
||||
const next = !current
|
||||
if (isMainRendererWindow(targetWindow)) {
|
||||
if (mainWindow && targetWindow === mainWindow) {
|
||||
setAllWindowsAlwaysOnTop(next)
|
||||
mainWindow?.webContents.send('window:always-on-top-changed', next)
|
||||
return
|
||||
@@ -805,6 +917,49 @@ function setupIPC(): void {
|
||||
return getProfileLibrary().importProfileFromPath(result.filePaths[0])
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:import-path', async (_event, path: string) => {
|
||||
return getProfileLibrary().importProfileFromPath(path)
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:prompt-unsaved', async (event, profileName: string | null) => {
|
||||
const targetWindow = getWindowFromSender(event.sender) ?? mainWindow ?? undefined
|
||||
const { response } = targetWindow
|
||||
? await dialog.showMessageBox(targetWindow, {
|
||||
type: 'warning',
|
||||
title: 'Unsaved Profile Changes',
|
||||
message: profileName
|
||||
? `Save changes to "${profileName}"?`
|
||||
: 'Save unsaved profile changes?',
|
||||
detail: 'Your profile changes will be lost if you continue without saving.',
|
||||
buttons: ['Save', 'Discard', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
noLink: true,
|
||||
})
|
||||
: await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'Unsaved Profile Changes',
|
||||
message: profileName
|
||||
? `Save changes to "${profileName}"?`
|
||||
: 'Save unsaved profile changes?',
|
||||
detail: 'Your profile changes will be lost if you continue without saving.',
|
||||
buttons: ['Save', 'Discard', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
noLink: true,
|
||||
})
|
||||
|
||||
if (response === 0) {
|
||||
return 'save'
|
||||
}
|
||||
|
||||
if (response === 1) {
|
||||
return 'discard'
|
||||
}
|
||||
|
||||
return 'cancel'
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:reveal-folder', async () => {
|
||||
const folderPath = getProfileLibrary().getProfilesDirectory()
|
||||
const openResult = await shell.openPath(folderPath)
|
||||
@@ -936,6 +1091,14 @@ function setupIPC(): void {
|
||||
applySettingsHeight(targetWindow, panelHeight)
|
||||
})
|
||||
|
||||
ipcMain.on('renderer:ready', (event) => {
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!isMainRendererWindow(targetWindow)) return
|
||||
|
||||
mainRendererReady = true
|
||||
void processPendingProfileOpenPaths()
|
||||
})
|
||||
|
||||
ipcMain.on('scope-popout:sync', (event, state: ScopePopoutSyncStateMap) => {
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!isMainRendererWindow(targetWindow)) return
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
LegacyThemeMigrationResult,
|
||||
ThemeLibrarySnapshot,
|
||||
} from '../types/theme'
|
||||
import type { ResizeDirection } from '../types/windowResize'
|
||||
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
|
||||
|
||||
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI
|
||||
@@ -32,6 +33,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
startWindowMove: () => ipcRenderer.send('window:start-move'),
|
||||
stopWindowMove: () => ipcRenderer.send('window:stop-move'),
|
||||
startWindowResize: (edge: ResizeDirection) => ipcRenderer.send('window:start-resize', edge),
|
||||
stopWindowResize: () => ipcRenderer.send('window:stop-resize'),
|
||||
setWindowBounds: (bounds: WindowBounds) => ipcRenderer.send('window:set-bounds', bounds),
|
||||
getWindowBounds: () => ipcRenderer.invoke('window:get-bounds') as Promise<WindowBounds | null>,
|
||||
repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position),
|
||||
@@ -52,6 +55,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
deleteProfile: (id: string) => ipcRenderer.invoke('profiles:delete', id) as Promise<ProfileLibrarySnapshot>,
|
||||
renameProfile: (id: string, name: string) => ipcRenderer.invoke('profiles:rename', id, name) as Promise<ProfileLibrarySnapshot>,
|
||||
importProfileDialog: () => ipcRenderer.invoke('profiles:import-dialog') as Promise<ProfileLibrarySnapshot | null>,
|
||||
importProfileFromPath: (path: string) => ipcRenderer.invoke('profiles:import-path', path) as Promise<ProfileLibrarySnapshot>,
|
||||
promptUnsavedProfileChanges: (profileName: string | null) => {
|
||||
return ipcRenderer.invoke('profiles:prompt-unsaved', profileName) as Promise<'save' | 'discard' | 'cancel'>
|
||||
},
|
||||
revealProfilesFolder: () => ipcRenderer.invoke('profiles:reveal-folder') as Promise<void>,
|
||||
migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => ipcRenderer.invoke('profiles:migrate-legacy', payload) as Promise<LegacyProfileMigrationResult>,
|
||||
getThemeSnapshot: () => ipcRenderer.invoke('themes:get-snapshot') as Promise<ThemeLibrarySnapshot>,
|
||||
@@ -65,6 +72,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
|
||||
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
|
||||
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
|
||||
notifyRendererReady: () => ipcRenderer.send('renderer:ready'),
|
||||
respondToCloseRequest: (shouldClose: boolean) => ipcRenderer.send('window:close-response', shouldClose),
|
||||
openProfileMenu: (request: ProfileMenuRequest) => ipcRenderer.send('profile-menu:open', request),
|
||||
syncScopePopouts: (state: ScopePopoutSyncStateMap) => ipcRenderer.send('scope-popout:sync', state),
|
||||
sendScopePopoutSnapshot: (snapshot: ScopePopoutSnapshot) => ipcRenderer.send('scope-popout:snapshot', snapshot),
|
||||
@@ -93,6 +102,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('shortcut:toggle-settings', handler)
|
||||
return () => ipcRenderer.removeListener('shortcut:toggle-settings', handler)
|
||||
},
|
||||
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, bounds: WindowBounds): void => callback(bounds)
|
||||
ipcRenderer.on('window:bounds-changed', handler)
|
||||
return () => ipcRenderer.removeListener('window:bounds-changed', handler)
|
||||
},
|
||||
onMainCloseRequested: (callback: () => void) => {
|
||||
const handler = (): void => callback()
|
||||
ipcRenderer.on('window:close-requested', handler)
|
||||
return () => ipcRenderer.removeListener('window:close-requested', handler)
|
||||
},
|
||||
onProfileMenuClosed: (callback: () => void) => {
|
||||
const handler = (): void => callback()
|
||||
ipcRenderer.on('profile-menu:closed', handler)
|
||||
@@ -133,6 +152,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('profile-menu:show-folder', handler)
|
||||
return () => ipcRenderer.removeListener('profile-menu:show-folder', handler)
|
||||
},
|
||||
onExternalProfileOpenRequested: (callback: (path: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, path: string): void => callback(path)
|
||||
ipcRenderer.on('profiles:open-requested', handler)
|
||||
return () => ipcRenderer.removeListener('profiles:open-requested', handler)
|
||||
},
|
||||
onExternalProfileActivated: (callback: (snapshot: ProfileLibrarySnapshot) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, snapshot: ProfileLibrarySnapshot): void => callback(snapshot)
|
||||
ipcRenderer.on('profiles:external-activated', handler)
|
||||
|
||||
+50
-1
@@ -4,6 +4,7 @@ import Toolbar from './components/Toolbar'
|
||||
import SettingsPanel from './components/SettingsPanel'
|
||||
import BottomBar from './components/BottomBar'
|
||||
import ScopePopoutBridge from './components/ScopePopoutBridge'
|
||||
import WindowResizeOverlay from './components/WindowResizeOverlay'
|
||||
import { useSettingsStore } from './stores/settingsStore'
|
||||
import { useAudioStore } from './stores/audioStore'
|
||||
import { useThemeStore } from './stores/themeStore'
|
||||
@@ -17,10 +18,14 @@ export default function App(): JSX.Element {
|
||||
const [settingsPanelHeight, setSettingsPanelHeight] = useState(0)
|
||||
const [bottomBarHeight, setBottomBarHeight] = useState(0)
|
||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const externalProfileOpenQueueRef = useRef(Promise.resolve())
|
||||
|
||||
const toggleScope = useSettingsStore((s) => s.toggleScope)
|
||||
const initializeProfiles = useSettingsStore((s) => s.initializeProfiles)
|
||||
const applyExternalProfileSnapshot = useSettingsStore((s) => s.applyExternalProfileSnapshot)
|
||||
const guardProfileTransition = useSettingsStore((s) => s.guardProfileTransition)
|
||||
const importProfileFromPath = useSettingsStore((s) => s.importProfileFromPath)
|
||||
const updateMainWindowBounds = useSettingsStore((s) => s.updateMainWindowBounds)
|
||||
const initializeThemes = useThemeStore((s) => s.initializeThemes)
|
||||
const applyExternalThemeSnapshot = useThemeStore((s) => s.applyExternalThemeSnapshot)
|
||||
|
||||
@@ -33,9 +38,14 @@ export default function App(): JSX.Element {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isDisposed = false
|
||||
|
||||
void (async () => {
|
||||
await initializeThemes()
|
||||
await initializeProfiles()
|
||||
if (!isDisposed) {
|
||||
window.electronAPI.notifyRendererReady()
|
||||
}
|
||||
})()
|
||||
|
||||
const unsubscribeProfile = window.electronAPI.onExternalProfileActivated((snapshot) => {
|
||||
@@ -44,12 +54,49 @@ export default function App(): JSX.Element {
|
||||
const unsubscribeTheme = window.electronAPI.onExternalThemeActivated((snapshot) => {
|
||||
applyExternalThemeSnapshot(snapshot)
|
||||
})
|
||||
const unsubscribeBounds = window.electronAPI.onMainWindowBoundsChanged((bounds) => {
|
||||
updateMainWindowBounds(bounds)
|
||||
})
|
||||
const unsubscribeExternalOpenRequested = window.electronAPI.onExternalProfileOpenRequested((path) => {
|
||||
externalProfileOpenQueueRef.current = externalProfileOpenQueueRef.current
|
||||
.then(async () => {
|
||||
const didComplete = await guardProfileTransition(async () => {
|
||||
await importProfileFromPath(path)
|
||||
})
|
||||
if (!didComplete) {
|
||||
return
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
window.alert(error instanceof Error && error.message
|
||||
? error.message
|
||||
: `Prism could not open ${path}.`)
|
||||
})
|
||||
})
|
||||
const unsubscribeCloseRequested = window.electronAPI.onMainCloseRequested(() => {
|
||||
void (async () => {
|
||||
const shouldClose = await guardProfileTransition(async () => {})
|
||||
window.electronAPI.respondToCloseRequest(shouldClose)
|
||||
})()
|
||||
})
|
||||
|
||||
return () => {
|
||||
isDisposed = true
|
||||
unsubscribeProfile()
|
||||
unsubscribeTheme()
|
||||
unsubscribeBounds()
|
||||
unsubscribeExternalOpenRequested()
|
||||
unsubscribeCloseRequested()
|
||||
}
|
||||
}, [applyExternalProfileSnapshot, applyExternalThemeSnapshot, initializeProfiles, initializeThemes])
|
||||
}, [
|
||||
applyExternalProfileSnapshot,
|
||||
applyExternalThemeSnapshot,
|
||||
guardProfileTransition,
|
||||
importProfileFromPath,
|
||||
initializeProfiles,
|
||||
initializeThemes,
|
||||
updateMainWindowBounds,
|
||||
])
|
||||
|
||||
const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0
|
||||
? settingsPanelHeight + bottomBarHeight
|
||||
@@ -153,6 +200,8 @@ export default function App(): JSX.Element {
|
||||
<BottomBar onClose={handleCloseSettings} onHeightChange={setBottomBarHeight} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WindowResizeOverlay />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useThemeStore } from '../stores/themeStore'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
|
||||
import { SCOPE_KINDS } from '../../types/scope'
|
||||
import ThemedSelect from './ThemedSelect'
|
||||
|
||||
const SCOPE_LABELS: Record<ScopeKind, string> = {
|
||||
spectrum: 'Spectrum',
|
||||
@@ -198,19 +199,19 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
<div className="bottom-bar__section-title">Theme</div>
|
||||
<div className="bottom-bar__section-body">
|
||||
<div className="bottom-bar__inline bottom-bar__inline--theme">
|
||||
<select
|
||||
className="settings-control__select"
|
||||
<ThemedSelect
|
||||
value={activeThemeId ?? ''}
|
||||
onChange={(event) => {
|
||||
void handleThemeChange(event.target.value)
|
||||
}}
|
||||
className="bottom-bar__select"
|
||||
>
|
||||
{themeEntries.map(([id, theme]) => (
|
||||
<option key={id} value={id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ThemedSelect>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-chip"
|
||||
@@ -277,12 +278,12 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
<div className="bottom-bar__section-title">Audio Source</div>
|
||||
<div className="bottom-bar__section-body">
|
||||
<div className="bottom-bar__inline">
|
||||
<select
|
||||
className="settings-control__select"
|
||||
<ThemedSelect
|
||||
value={selectedSourceValue}
|
||||
onChange={(event) => {
|
||||
void handleSourceChange(event.target.value)
|
||||
}}
|
||||
className="bottom-bar__select"
|
||||
>
|
||||
<optgroup label="Output Devices">
|
||||
{visibleSystemSources.map((source) => (
|
||||
@@ -300,7 +301,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
))}
|
||||
</optgroup>
|
||||
) : null}
|
||||
</select>
|
||||
</ThemedSelect>
|
||||
|
||||
<div className={`settings-status-pill is-${captureStatus}`.trim()}>
|
||||
<span className="settings-status-pill__dot" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSProperties, JSX, ReactNode } from 'react'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { SCOPE_LABELS } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import ThemedSelect from './ThemedSelect'
|
||||
|
||||
function vectorscopeModeLabel(mode: ScopeSettings['vectorscope']['mode']): string {
|
||||
switch (mode) {
|
||||
@@ -111,13 +112,12 @@ function SelectControl({
|
||||
return (
|
||||
<label className="settings-control">
|
||||
<span className="settings-control__label">{label}</span>
|
||||
<select
|
||||
className="settings-control__select"
|
||||
<ThemedSelect
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</ThemedSelect>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { JSX, SelectHTMLAttributes } from 'react'
|
||||
|
||||
function ChevronIcon(): JSX.Element {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
d="M4.5 6.5 8 10l3.5-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface ThemedSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ThemedSelect({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ThemedSelectProps): JSX.Element {
|
||||
return (
|
||||
<div className={['settings-control__select', 'themed-select', className].filter(Boolean).join(' ')}>
|
||||
<select className="themed-select__control" {...props}>
|
||||
{children}
|
||||
</select>
|
||||
<span className="themed-select__chevron" aria-hidden="true">
|
||||
<ChevronIcon />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -79,6 +79,8 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
|
||||
const profiles = useSettingsStore((s) => s.profiles)
|
||||
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
|
||||
const hasUnsavedProfileChanges = useSettingsStore((s) => s.hasUnsavedProfileChanges)
|
||||
const guardProfileTransition = useSettingsStore((s) => s.guardProfileTransition)
|
||||
const saveProfile = useSettingsStore((s) => s.saveProfile)
|
||||
const loadProfile = useSettingsStore((s) => s.loadProfile)
|
||||
const deleteProfile = useSettingsStore((s) => s.deleteProfile)
|
||||
@@ -163,23 +165,27 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
|
||||
const handleLoadProfile = useCallback(async (id: string) => {
|
||||
try {
|
||||
await loadProfile(id)
|
||||
await guardProfileTransition(async () => {
|
||||
await loadProfile(id)
|
||||
})
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not load the profile.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [loadProfile])
|
||||
}, [guardProfileTransition, loadProfile])
|
||||
|
||||
const handleImportProfile = useCallback(async () => {
|
||||
try {
|
||||
await importProfileFromDialog()
|
||||
await guardProfileTransition(async () => {
|
||||
await importProfileFromDialog()
|
||||
})
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not import the profile file.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [importProfileFromDialog])
|
||||
}, [guardProfileTransition, importProfileFromDialog])
|
||||
|
||||
const handleShowProfilesFolder = useCallback(async () => {
|
||||
try {
|
||||
@@ -314,6 +320,7 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
<span className="toolbar__profile-name">
|
||||
{activeProfile?.name ?? 'Profiles'}
|
||||
</span>
|
||||
{hasUnsavedProfileChanges ? <span className="toolbar__profile-dirty-dot" aria-hidden="true" /> : null}
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../../types/windowResize'
|
||||
|
||||
const RESIZE_HANDLE_CLASSNAMES: Record<ResizeDirection, string> = {
|
||||
n: 'window-resize-overlay__handle window-resize-overlay__handle--n',
|
||||
s: 'window-resize-overlay__handle window-resize-overlay__handle--s',
|
||||
e: 'window-resize-overlay__handle window-resize-overlay__handle--e',
|
||||
w: 'window-resize-overlay__handle window-resize-overlay__handle--w',
|
||||
ne: 'window-resize-overlay__handle window-resize-overlay__handle--ne',
|
||||
nw: 'window-resize-overlay__handle window-resize-overlay__handle--nw',
|
||||
se: 'window-resize-overlay__handle window-resize-overlay__handle--se',
|
||||
sw: 'window-resize-overlay__handle window-resize-overlay__handle--sw',
|
||||
}
|
||||
|
||||
export default function WindowResizeOverlay(): JSX.Element | null {
|
||||
const isWindows = window.electronAPI.platform === 'win32'
|
||||
|
||||
const stopResize = useCallback(() => {
|
||||
window.electronAPI.stopWindowResize()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWindows) return
|
||||
|
||||
window.addEventListener('blur', stopResize)
|
||||
return () => {
|
||||
window.removeEventListener('blur', stopResize)
|
||||
stopResize()
|
||||
}
|
||||
}, [isWindows, stopResize])
|
||||
|
||||
const handlePointerDown = useCallback((direction: ResizeDirection) => {
|
||||
return (event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
window.electronAPI.startWindowResize(direction)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePointerEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
stopResize()
|
||||
}, [stopResize])
|
||||
|
||||
if (!isWindows) return null
|
||||
|
||||
return (
|
||||
<div className="window-resize-overlay" aria-hidden="true">
|
||||
{RESIZE_DIRECTIONS.map((direction) => (
|
||||
<div
|
||||
key={direction}
|
||||
className={RESIZE_HANDLE_CLASSNAMES[direction]}
|
||||
onPointerDown={handlePointerDown(direction)}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onLostPointerCapture={handlePointerEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+10
@@ -23,6 +23,7 @@ import type {
|
||||
LegacyThemeMigrationResult,
|
||||
ThemeLibrarySnapshot,
|
||||
} from '../types/theme'
|
||||
import type { ResizeDirection } from '../types/windowResize'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -34,6 +35,8 @@ declare global {
|
||||
close: () => void
|
||||
startWindowMove: () => void
|
||||
stopWindowMove: () => void
|
||||
startWindowResize: (edge: ResizeDirection) => void
|
||||
stopWindowResize: () => void
|
||||
setWindowBounds: (bounds: WindowBounds) => void
|
||||
getWindowBounds: () => Promise<WindowBounds | null>
|
||||
repositionWindow: (position: 'top' | 'bottom') => void
|
||||
@@ -48,6 +51,8 @@ declare global {
|
||||
deleteProfile: (id: string) => Promise<ProfileLibrarySnapshot>
|
||||
renameProfile: (id: string, name: string) => Promise<ProfileLibrarySnapshot>
|
||||
importProfileDialog: () => Promise<ProfileLibrarySnapshot | null>
|
||||
importProfileFromPath: (path: string) => Promise<ProfileLibrarySnapshot>
|
||||
promptUnsavedProfileChanges: (profileName: string | null) => Promise<'save' | 'discard' | 'cancel'>
|
||||
revealProfilesFolder: () => Promise<void>
|
||||
migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => Promise<LegacyProfileMigrationResult>
|
||||
getThemeSnapshot: () => Promise<ThemeLibrarySnapshot>
|
||||
@@ -61,6 +66,8 @@ declare global {
|
||||
expandSettings: (panelHeight: number) => void
|
||||
collapseSettings: (panelHeight: number) => void
|
||||
setSettingsHeight: (panelHeight: number) => void
|
||||
notifyRendererReady: () => void
|
||||
respondToCloseRequest: (shouldClose: boolean) => void
|
||||
openProfileMenu: (request: ProfileMenuRequest) => void
|
||||
syncScopePopouts: (state: ScopePopoutSyncStateMap) => void
|
||||
sendScopePopoutSnapshot: (snapshot: ScopePopoutSnapshot) => void
|
||||
@@ -73,6 +80,8 @@ declare global {
|
||||
onToggleScope: (callback: (index: number) => void) => () => void
|
||||
onToggleCapture: (callback: () => void) => () => void
|
||||
onToggleSettings: (callback: () => void) => () => void
|
||||
onMainWindowBoundsChanged: (callback: (bounds: WindowBounds) => void) => () => void
|
||||
onMainCloseRequested: (callback: () => void) => () => void
|
||||
onProfileMenuClosed: (callback: () => void) => () => void
|
||||
onProfileMenuLoad: (callback: (id: string) => void) => () => void
|
||||
onProfileMenuSaveNew: (callback: () => void) => () => void
|
||||
@@ -81,6 +90,7 @@ declare global {
|
||||
onProfileMenuDeleteActive: (callback: (id: string) => void) => () => void
|
||||
onProfileMenuImport: (callback: () => void) => () => void
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings
|
||||
import { applyResolvedThemeToDocument, createDefaultTheme, resolveTheme } from '../../shared/themeState'
|
||||
import ScopeModule from '../components/ScopeModule'
|
||||
import ScopeSettingsSection from '../components/ScopeSettingsSection'
|
||||
import WindowResizeOverlay from '../components/WindowResizeOverlay'
|
||||
import { usePerformanceStore } from '../stores/performanceStore'
|
||||
import { ScopePopoutDataSource } from './ScopePopoutDataSource'
|
||||
import { FrameScheduler } from '../visualizers/frameScheduler'
|
||||
@@ -239,6 +240,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WindowResizeOverlay />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ScopePopoutStateMap, WindowBounds } from '../../types/popout'
|
||||
import type { Profile } from '../../types/profile'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import {
|
||||
cloneScopeSettings,
|
||||
normalizeProfile,
|
||||
normalizeScopePopouts,
|
||||
} from '../../shared/profileState'
|
||||
|
||||
export interface ProfileDraftSource {
|
||||
themeId: string | null
|
||||
scopeOrder: ScopeKind[]
|
||||
hiddenScopes: Iterable<ScopeKind>
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: ScopePopoutStateMap
|
||||
windowBounds?: WindowBounds
|
||||
}
|
||||
|
||||
export function buildProfileDraft(
|
||||
source: ProfileDraftSource,
|
||||
name: string,
|
||||
fallbackThemeId: string | null = null,
|
||||
): Profile {
|
||||
return normalizeProfile({
|
||||
name,
|
||||
themeId: source.themeId ?? fallbackThemeId,
|
||||
scopeOrder: [...source.scopeOrder],
|
||||
hiddenScopes: Array.from(source.hiddenScopes),
|
||||
widthWeights: { ...source.widthWeights },
|
||||
scopeSettings: cloneScopeSettings(source.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(source.scopePopouts),
|
||||
windowBounds: source.windowBounds,
|
||||
}, name)
|
||||
}
|
||||
|
||||
export function profilesMatch(left: Profile | null, right: Profile | null): boolean {
|
||||
if (left === null || right === null) {
|
||||
return left === right
|
||||
}
|
||||
|
||||
return JSON.stringify(normalizeProfile(left, left.name)) === JSON.stringify(normalizeProfile(right, right.name))
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import {
|
||||
cloneScopeSettings,
|
||||
createDefaultProfile,
|
||||
mergeScopeSettings,
|
||||
normalizeHiddenScopes,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
normalizeWidthWeights,
|
||||
} from '../../shared/profileState'
|
||||
import { useThemeStore } from './themeStore'
|
||||
import { buildProfileDraft, profilesMatch } from './profileDraft'
|
||||
|
||||
export type { ScopeSettings } from '../../types/settings'
|
||||
|
||||
@@ -34,6 +34,7 @@ interface PersistedSettingsState {
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: ScopePopoutStateMap
|
||||
windowBounds?: WindowBounds
|
||||
}
|
||||
|
||||
interface WorkingSettingsState {
|
||||
@@ -43,12 +44,15 @@ interface WorkingSettingsState {
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: ScopePopoutStateMap
|
||||
windowBounds?: WindowBounds
|
||||
}
|
||||
|
||||
interface SettingsState extends WorkingSettingsState {
|
||||
visibleScopes: () => ScopeKind[]
|
||||
profiles: Record<string, Profile>
|
||||
activeProfileId: string | null
|
||||
savedProfileBaseline: Profile | null
|
||||
hasUnsavedProfileChanges: boolean
|
||||
initializeProfiles: () => Promise<void>
|
||||
applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => void
|
||||
setThemeId: (themeId: string | null) => void
|
||||
@@ -59,6 +63,9 @@ interface SettingsState extends WorkingSettingsState {
|
||||
popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => void
|
||||
popInScope: (kind: ScopeKind) => void
|
||||
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => void
|
||||
updateMainWindowBounds: (bounds: WindowBounds) => void
|
||||
discardUnsavedProfileChanges: () => Promise<void>
|
||||
guardProfileTransition: (transition: () => Promise<void>) => Promise<boolean>
|
||||
saveProfile: (name: string) => Promise<string | null>
|
||||
saveProfileAs: (name: string) => Promise<string | null>
|
||||
updateActiveProfile: () => Promise<void>
|
||||
@@ -66,6 +73,7 @@ interface SettingsState extends WorkingSettingsState {
|
||||
deleteProfile: (id: string) => Promise<void>
|
||||
renameProfile: (id: string, name: string) => Promise<void>
|
||||
importProfileFromDialog: () => Promise<void>
|
||||
importProfileFromPath: (path: string) => Promise<void>
|
||||
showProfilesFolder: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -73,11 +81,19 @@ function canUseElectronAPI(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined'
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
function loadFromStorage(): Partial<PersistedSettingsState> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw) as Partial<PersistedSettingsState>
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore localStorage read failures.
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -90,8 +106,17 @@ function saveToStorage(state: WorkingSettingsState): void {
|
||||
widthWeights: state.widthWeights,
|
||||
scopeSettings: state.scopeSettings,
|
||||
scopePopouts: state.scopePopouts,
|
||||
windowBounds: state.windowBounds,
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore localStorage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
function persistWorkingState(state: WorkingSettingsState): void {
|
||||
if (!canUseElectronAPI()) {
|
||||
saveToStorage(state)
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null {
|
||||
@@ -119,7 +144,22 @@ function clearLegacyProfileStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(PROFILES_STORAGE_KEY)
|
||||
localStorage.removeItem(ACTIVE_PROFILE_KEY)
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore localStorage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLoadedProfileForBaseline(profile: Profile, activeProfileId: string | null): Profile {
|
||||
const normalizedProfile = normalizeProfile(profile, profile.name)
|
||||
if (activeProfileId !== DEFAULT_PROFILE_ID || normalizedProfile.themeId) {
|
||||
return normalizedProfile
|
||||
}
|
||||
|
||||
const activeThemeId = useThemeStore.getState().activeThemeId
|
||||
return normalizeProfile({
|
||||
...normalizedProfile,
|
||||
themeId: activeThemeId,
|
||||
}, normalizedProfile.name)
|
||||
}
|
||||
|
||||
function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState {
|
||||
@@ -132,67 +172,152 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState {
|
||||
widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(normalizedProfile.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(normalizedProfile.scopePopouts),
|
||||
windowBounds: normalizedProfile.windowBounds,
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveProfileName(state: Pick<SettingsState, 'activeProfileId' | 'profiles'>): string | null {
|
||||
if (!state.activeProfileId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return state.profiles[state.activeProfileId]?.name ?? null
|
||||
}
|
||||
|
||||
function buildActiveProfileDraft(state: SettingsState): Profile | null {
|
||||
const activeProfileName = getActiveProfileName(state)
|
||||
if (!activeProfileName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildProfileDraft(
|
||||
state,
|
||||
activeProfileName,
|
||||
useThemeStore.getState().activeThemeId,
|
||||
)
|
||||
}
|
||||
|
||||
export function hasProfileDraftChanges(state: SettingsState, baseline = state.savedProfileBaseline): boolean {
|
||||
if (!baseline) {
|
||||
return false
|
||||
}
|
||||
|
||||
const draft = buildActiveProfileDraft(state)
|
||||
if (!draft) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !profilesMatch(draft, baseline)
|
||||
}
|
||||
|
||||
function withProfileDraftState(state: SettingsState, baseline = state.savedProfileBaseline): SettingsState {
|
||||
return {
|
||||
...state,
|
||||
savedProfileBaseline: baseline,
|
||||
hasUnsavedProfileChanges: hasProfileDraftChanges(state, baseline),
|
||||
}
|
||||
}
|
||||
|
||||
function commitWorkingState(
|
||||
state: SettingsState,
|
||||
patch: Partial<WorkingSettingsState>,
|
||||
baseline = state.savedProfileBaseline,
|
||||
): SettingsState {
|
||||
const nextState = withProfileDraftState({
|
||||
...state,
|
||||
...patch,
|
||||
}, baseline)
|
||||
|
||||
persistWorkingState(nextState)
|
||||
return nextState
|
||||
}
|
||||
|
||||
function syncMissingBaselineWindowBounds(state: SettingsState, bounds: WindowBounds): Profile | null {
|
||||
const baseline = state.savedProfileBaseline
|
||||
if (!baseline || state.hasUnsavedProfileChanges || baseline.windowBounds) {
|
||||
return baseline
|
||||
}
|
||||
|
||||
return normalizeProfile({
|
||||
...baseline,
|
||||
windowBounds: bounds,
|
||||
}, baseline.name)
|
||||
}
|
||||
|
||||
function syncMissingBaselinePopoutBounds(
|
||||
state: SettingsState,
|
||||
kind: ScopeKind,
|
||||
bounds: WindowBounds,
|
||||
): Profile | null {
|
||||
const baseline = state.savedProfileBaseline
|
||||
const baselinePopout = baseline?.scopePopouts[kind]
|
||||
if (!baseline || state.hasUnsavedProfileChanges || !baselinePopout?.poppedOut || baselinePopout.windowBounds) {
|
||||
return baseline
|
||||
}
|
||||
|
||||
return normalizeProfile({
|
||||
...baseline,
|
||||
scopePopouts: {
|
||||
...baseline.scopePopouts,
|
||||
[kind]: {
|
||||
...baselinePopout,
|
||||
windowBounds: bounds,
|
||||
},
|
||||
},
|
||||
}, baseline.name)
|
||||
}
|
||||
|
||||
function applyLoadedProfileEffects(profile: Profile | null): void {
|
||||
if (!profile) {
|
||||
return
|
||||
}
|
||||
|
||||
const { activeThemeId, themes, loadTheme } = useThemeStore.getState()
|
||||
if (profile.themeId && profile.themeId !== activeThemeId && themes[profile.themeId]) {
|
||||
void loadTheme(profile.themeId)
|
||||
}
|
||||
|
||||
if (profile.windowBounds && canUseElectronAPI()) {
|
||||
window.electronAPI.setWindowBounds(profile.windowBounds)
|
||||
}
|
||||
}
|
||||
|
||||
function applyProfileSnapshot(
|
||||
set: (partial: Partial<SettingsState>) => void,
|
||||
set: (updater: (state: SettingsState) => SettingsState) => void,
|
||||
snapshot: ProfileLibrarySnapshot,
|
||||
options: { loadActiveProfile: boolean },
|
||||
): void {
|
||||
const activeProfile = snapshot.activeProfileId
|
||||
? snapshot.profiles[snapshot.activeProfileId] ?? null
|
||||
: null
|
||||
const baselineProfile = activeProfile
|
||||
? normalizeLoadedProfileForBaseline(activeProfile, snapshot.activeProfileId)
|
||||
: null
|
||||
|
||||
if (!options.loadActiveProfile || !activeProfile) {
|
||||
set({
|
||||
set((state) => {
|
||||
if (!options.loadActiveProfile || !baselineProfile) {
|
||||
return withProfileDraftState({
|
||||
...state,
|
||||
profiles: snapshot.profiles,
|
||||
activeProfileId: snapshot.activeProfileId,
|
||||
}, baselineProfile)
|
||||
}
|
||||
|
||||
const nextWorkingState = createWorkingStateFromProfile(baselineProfile)
|
||||
const nextState = withProfileDraftState({
|
||||
...state,
|
||||
...nextWorkingState,
|
||||
profiles: snapshot.profiles,
|
||||
activeProfileId: snapshot.activeProfileId,
|
||||
})
|
||||
return
|
||||
}
|
||||
}, baselineProfile)
|
||||
|
||||
const nextState = createWorkingStateFromProfile(activeProfile)
|
||||
if (!nextState.themeId && snapshot.activeProfileId === DEFAULT_PROFILE_ID) {
|
||||
nextState.themeId = useThemeStore.getState().activeThemeId
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
set({
|
||||
...nextState,
|
||||
profiles: snapshot.profiles,
|
||||
activeProfileId: snapshot.activeProfileId,
|
||||
persistWorkingState(nextState)
|
||||
return nextState
|
||||
})
|
||||
|
||||
if (activeProfile.themeId && useThemeStore.getState().themes[activeProfile.themeId]) {
|
||||
void useThemeStore.getState().loadTheme(activeProfile.themeId)
|
||||
if (options.loadActiveProfile) {
|
||||
applyLoadedProfileEffects(activeProfile)
|
||||
}
|
||||
|
||||
if (activeProfile.windowBounds && canUseElectronAPI()) {
|
||||
window.electronAPI.setWindowBounds(activeProfile.windowBounds)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildProfileFromState(state: SettingsState, name: string): Promise<Profile> {
|
||||
const profile = normalizeProfile({
|
||||
name,
|
||||
themeId: state.themeId ?? useThemeStore.getState().activeThemeId,
|
||||
scopeOrder: [...state.scopeOrder],
|
||||
hiddenScopes: Array.from(state.hiddenScopes),
|
||||
widthWeights: { ...state.widthWeights },
|
||||
scopeSettings: cloneScopeSettings(state.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(state.scopePopouts),
|
||||
}, name)
|
||||
|
||||
if (!canUseElectronAPI()) {
|
||||
return profile
|
||||
}
|
||||
|
||||
const bounds = await window.electronAPI.getWindowBounds()
|
||||
if (bounds) {
|
||||
profile.windowBounds = bounds
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
function isDockedScope(
|
||||
@@ -238,7 +363,37 @@ export function moveDockedScopeOrder(
|
||||
return didChange ? mergedOrder : scopeOrder
|
||||
}
|
||||
|
||||
const stored = loadFromStorage()
|
||||
async function restoreSavedProfileBaseline(
|
||||
set: (updater: (state: SettingsState) => SettingsState) => void,
|
||||
get: () => SettingsState,
|
||||
): Promise<void> {
|
||||
const baseline = get().savedProfileBaseline
|
||||
if (!baseline) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentThemeId = useThemeStore.getState().activeThemeId
|
||||
if (baseline.themeId && baseline.themeId !== currentThemeId && useThemeStore.getState().themes[baseline.themeId]) {
|
||||
await useThemeStore.getState().loadTheme(baseline.themeId)
|
||||
}
|
||||
|
||||
if (baseline.windowBounds && canUseElectronAPI()) {
|
||||
window.electronAPI.setWindowBounds(baseline.windowBounds)
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const nextWorkingState = createWorkingStateFromProfile(baseline)
|
||||
const nextState = withProfileDraftState({
|
||||
...state,
|
||||
...nextWorkingState,
|
||||
}, baseline)
|
||||
|
||||
persistWorkingState(nextState)
|
||||
return nextState
|
||||
})
|
||||
}
|
||||
|
||||
const stored = canUseElectronAPI() ? {} : loadFromStorage()
|
||||
const initialWorkingState: WorkingSettingsState = {
|
||||
themeId: typeof stored.themeId === 'string' && stored.themeId.trim()
|
||||
? stored.themeId.trim()
|
||||
@@ -248,6 +403,7 @@ const initialWorkingState: WorkingSettingsState = {
|
||||
widthWeights: normalizeWidthWeights(stored.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(stored.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(stored.scopePopouts),
|
||||
windowBounds: stored.windowBounds,
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
@@ -256,6 +412,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
[DEFAULT_PROFILE_ID]: createDefaultProfile(DEFAULT_PROFILE_NAME),
|
||||
},
|
||||
activeProfileId: null,
|
||||
savedProfileBaseline: null,
|
||||
hasUnsavedProfileChanges: false,
|
||||
|
||||
visibleScopes: () => {
|
||||
const { scopeOrder, hiddenScopes } = get()
|
||||
@@ -284,14 +442,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
},
|
||||
|
||||
setThemeId: (themeId: string | null) => {
|
||||
set((state) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
themeId,
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
set((state) => commitWorkingState(state, { themeId }))
|
||||
},
|
||||
|
||||
toggleScope: (kind: ScopeKind) => {
|
||||
@@ -301,13 +452,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
next.delete(kind)
|
||||
} else {
|
||||
const visibleCount = state.scopeOrder.filter((scope) => !next.has(scope)).length
|
||||
if (visibleCount <= 1) return state
|
||||
if (visibleCount <= 1) {
|
||||
return state
|
||||
}
|
||||
next.add(kind)
|
||||
}
|
||||
|
||||
const nextState = { ...state, hiddenScopes: next }
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
return commitWorkingState(state, { hiddenScopes: next })
|
||||
})
|
||||
},
|
||||
|
||||
@@ -320,43 +471,36 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
kind,
|
||||
direction,
|
||||
)
|
||||
if (nextOrder === state.scopeOrder) return state
|
||||
if (nextOrder === state.scopeOrder) {
|
||||
return state
|
||||
}
|
||||
|
||||
const nextState = { ...state, scopeOrder: nextOrder }
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
return commitWorkingState(state, { scopeOrder: nextOrder })
|
||||
})
|
||||
},
|
||||
|
||||
setScopeWidthWeight: (kind: ScopeKind, weight: number) => {
|
||||
set((state) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
return commitWorkingState(state, {
|
||||
widthWeights: { ...state.widthWeights, [kind]: Math.max(0.1, weight) },
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => {
|
||||
set((state) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
return commitWorkingState(state, {
|
||||
scopeSettings: {
|
||||
...state.scopeSettings,
|
||||
[kind]: { ...state.scopeSettings[kind], ...settings },
|
||||
},
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => {
|
||||
set((state) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
return commitWorkingState(state, {
|
||||
scopePopouts: {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
@@ -364,16 +508,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
windowBounds: bounds ?? state.scopePopouts[kind]?.windowBounds,
|
||||
},
|
||||
},
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
popInScope: (kind: ScopeKind) => {
|
||||
set((state) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
return commitWorkingState(state, {
|
||||
scopePopouts: {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
@@ -381,16 +522,14 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
poppedOut: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => {
|
||||
set((state) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
const nextBaseline = syncMissingBaselinePopoutBounds(state, kind, bounds)
|
||||
return commitWorkingState(state, {
|
||||
scopePopouts: {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
@@ -398,16 +537,55 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
windowBounds: bounds,
|
||||
},
|
||||
},
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
}, nextBaseline)
|
||||
})
|
||||
},
|
||||
|
||||
updateMainWindowBounds: (bounds: WindowBounds) => {
|
||||
set((state) => {
|
||||
const nextBaseline = syncMissingBaselineWindowBounds(state, bounds)
|
||||
return commitWorkingState(state, { windowBounds: bounds }, nextBaseline)
|
||||
})
|
||||
},
|
||||
|
||||
discardUnsavedProfileChanges: async () => {
|
||||
await restoreSavedProfileBaseline(set, get)
|
||||
},
|
||||
|
||||
guardProfileTransition: async (transition) => {
|
||||
const state = get()
|
||||
if (!canUseElectronAPI() || !state.hasUnsavedProfileChanges || !state.savedProfileBaseline) {
|
||||
await transition()
|
||||
return true
|
||||
}
|
||||
|
||||
const choice = await window.electronAPI.promptUnsavedProfileChanges(getActiveProfileName(state))
|
||||
if (choice === 'cancel') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (choice === 'save') {
|
||||
try {
|
||||
await get().updateActiveProfile()
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not save the profile.'))
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
await get().discardUnsavedProfileChanges()
|
||||
}
|
||||
|
||||
await transition()
|
||||
return true
|
||||
},
|
||||
|
||||
saveProfile: async (name: string) => {
|
||||
if (!canUseElectronAPI()) return null
|
||||
|
||||
const snapshot = await window.electronAPI.saveNewProfile(name, await buildProfileFromState(get(), name))
|
||||
const snapshot = await window.electronAPI.saveNewProfile(
|
||||
name,
|
||||
buildProfileDraft(get(), name, useThemeStore.getState().activeThemeId),
|
||||
)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
|
||||
return snapshot.activeProfileId
|
||||
},
|
||||
@@ -421,11 +599,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
|
||||
const state = get()
|
||||
const id = state.activeProfileId
|
||||
if (!id || !state.profiles[id]) return
|
||||
const name = getActiveProfileName(state)
|
||||
if (!id || !name) return
|
||||
|
||||
const snapshot = await window.electronAPI.overwriteProfile(
|
||||
id,
|
||||
await buildProfileFromState(state, state.profiles[id].name),
|
||||
buildProfileDraft(state, name, useThemeStore.getState().activeThemeId),
|
||||
)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
|
||||
},
|
||||
@@ -459,6 +638,13 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: true })
|
||||
},
|
||||
|
||||
importProfileFromPath: async (path: string) => {
|
||||
if (!canUseElectronAPI()) return
|
||||
|
||||
const snapshot = await window.electronAPI.importProfileFromPath(path)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: true })
|
||||
},
|
||||
|
||||
showProfilesFolder: async () => {
|
||||
if (!canUseElectronAPI()) return
|
||||
await window.electronAPI.revealProfilesFolder()
|
||||
|
||||
+196
-20
@@ -1,6 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #050505;
|
||||
--bg-tertiary: #0a0a0a;
|
||||
@@ -68,6 +69,12 @@ select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.prism-app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@@ -76,6 +83,85 @@ select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.window-resize-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--n,
|
||||
.window-resize-overlay__handle--s {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--e,
|
||||
.window-resize-overlay__handle--w {
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--n {
|
||||
top: 0;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--s {
|
||||
bottom: 0;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--e {
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--w {
|
||||
left: 0;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--ne,
|
||||
.window-resize-overlay__handle--nw,
|
||||
.window-resize-overlay__handle--se,
|
||||
.window-resize-overlay__handle--sw {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--ne {
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--nw {
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--se {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.window-resize-overlay__handle--sw {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.prism-toolbar-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -198,8 +284,9 @@ select {
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--control-border);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
@@ -213,7 +300,8 @@ select {
|
||||
.toolbar__profile-button:hover,
|
||||
.toolbar__profile-button.is-active {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--control-border-active);
|
||||
border-color: rgba(var(--accent-rgb), 0.26);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.toolbar__profile-name {
|
||||
@@ -223,6 +311,15 @@ select {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar__profile-dirty-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 8px rgba(255, 191, 0, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar__spacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -272,8 +369,9 @@ select {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
@@ -286,7 +384,8 @@ select {
|
||||
|
||||
.toolbar__chip:hover {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.toolbar__chip.is-active {
|
||||
@@ -307,7 +406,8 @@ select {
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -327,8 +427,8 @@ select {
|
||||
|
||||
.toolbar__icon-button:hover {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
background: var(--control-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -383,8 +483,8 @@ select {
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(8, 12, 18, 0.74);
|
||||
border: 1px solid transparent;
|
||||
background: rgba(8, 12, 18, 0.88);
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
@@ -435,7 +535,7 @@ select {
|
||||
|
||||
.scope-strip__reorder-button:disabled {
|
||||
color: rgba(255, 255, 255, 0.32);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -605,26 +705,78 @@ select {
|
||||
}
|
||||
|
||||
.settings-control__select {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
padding-right: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--input-border);
|
||||
border: 1px solid transparent;
|
||||
background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
|
||||
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.settings-control__select:hover,
|
||||
.settings-control__select:focus {
|
||||
.settings-control__select:focus-within {
|
||||
border-color: var(--input-border-focus);
|
||||
background: linear-gradient(180deg, var(--input-bg-focus), var(--settings-bg-bottom));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.themed-select__control {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.themed-select__control option,
|
||||
.themed-select__control optgroup {
|
||||
background: rgb(10, 14, 20);
|
||||
color: rgb(245, 247, 250);
|
||||
}
|
||||
|
||||
.themed-select__control optgroup {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.themed-select__chevron {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.themed-select__chevron svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.settings-control__select:hover .themed-select__chevron,
|
||||
.settings-control__select:focus-within .themed-select__chevron {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-control__range {
|
||||
width: 100%;
|
||||
--range-percent: 50%;
|
||||
@@ -748,7 +900,7 @@ select {
|
||||
|
||||
.settings-chip:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--control-border);
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
@@ -847,17 +999,18 @@ select {
|
||||
width: 84px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border: 1px solid transparent;
|
||||
background: linear-gradient(180deg, rgba(10, 14, 20, 0.96), rgba(5, 8, 12, 0.96));
|
||||
cursor: pointer;
|
||||
padding: 3px 4px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.settings-panel__close {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--control-border);
|
||||
border: 1px solid transparent;
|
||||
background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom));
|
||||
color: var(--text-secondary);
|
||||
font-size: 9px;
|
||||
@@ -995,6 +1148,11 @@ select {
|
||||
}
|
||||
|
||||
.bottom-bar .settings-control__select {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.bottom-bar__select {
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
}
|
||||
@@ -1151,8 +1309,9 @@ select {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--control-border);
|
||||
border: 1px solid transparent;
|
||||
background: var(--control-bg);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
@@ -1171,6 +1330,23 @@ select {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toolbar__profile-button:focus-visible,
|
||||
.toolbar__chip:focus-visible,
|
||||
.toolbar__icon-button:focus-visible,
|
||||
.scope-strip__reorder-button:focus-visible,
|
||||
.scope-strip__popout-button:focus-visible,
|
||||
.settings-chip:focus-visible,
|
||||
.settings-accent-input:focus-visible,
|
||||
.settings-panel__close:focus-visible,
|
||||
.scope-popout__button:focus-visible,
|
||||
.settings-control__select:focus-within {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
0 0 0 1px rgba(var(--accent-rgb), 0.32),
|
||||
0 0 0 4px rgba(var(--accent-rgb), 0.12);
|
||||
}
|
||||
|
||||
.scope-popout__settings-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { WindowBounds } from '../types/popout'
|
||||
import type { ResizeDirection } from '../types/windowResize'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface ResizeWindowBoundsOptions {
|
||||
edge: ResizeDirection
|
||||
startBounds: WindowBounds
|
||||
startCursor: Point
|
||||
cursor: Point
|
||||
minWidth: number
|
||||
minHeight: number
|
||||
}
|
||||
|
||||
export function calculateResizedWindowBounds({
|
||||
edge,
|
||||
startBounds,
|
||||
startCursor,
|
||||
cursor,
|
||||
minWidth,
|
||||
minHeight,
|
||||
}: ResizeWindowBoundsOptions): WindowBounds {
|
||||
const deltaX = Math.round(cursor.x - startCursor.x)
|
||||
const deltaY = Math.round(cursor.y - startCursor.y)
|
||||
|
||||
let nextX = startBounds.x
|
||||
let nextY = startBounds.y
|
||||
let nextWidth = startBounds.width
|
||||
let nextHeight = startBounds.height
|
||||
|
||||
if (edge.includes('e')) {
|
||||
nextWidth = startBounds.width + deltaX
|
||||
}
|
||||
|
||||
if (edge.includes('s')) {
|
||||
nextHeight = startBounds.height + deltaY
|
||||
}
|
||||
|
||||
if (edge.includes('w')) {
|
||||
nextX = startBounds.x + deltaX
|
||||
nextWidth = startBounds.width - deltaX
|
||||
}
|
||||
|
||||
if (edge.includes('n')) {
|
||||
nextY = startBounds.y + deltaY
|
||||
nextHeight = startBounds.height - deltaY
|
||||
}
|
||||
|
||||
const clampedMinWidth = Math.max(1, Math.round(minWidth))
|
||||
const clampedMinHeight = Math.max(1, Math.round(minHeight))
|
||||
|
||||
if (nextWidth < clampedMinWidth) {
|
||||
nextWidth = clampedMinWidth
|
||||
if (edge.includes('w')) {
|
||||
nextX = startBounds.x + startBounds.width - clampedMinWidth
|
||||
}
|
||||
}
|
||||
|
||||
if (nextHeight < clampedMinHeight) {
|
||||
nextHeight = clampedMinHeight
|
||||
if (edge.includes('n')) {
|
||||
nextY = startBounds.y + startBounds.height - clampedMinHeight
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(nextX),
|
||||
y: Math.round(nextY),
|
||||
width: Math.round(nextWidth),
|
||||
height: Math.round(nextHeight),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
|
||||
export const RESIZE_DIRECTIONS: ResizeDirection[] = [
|
||||
'n',
|
||||
's',
|
||||
'e',
|
||||
'w',
|
||||
'ne',
|
||||
'nw',
|
||||
'se',
|
||||
'sw',
|
||||
]
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
import {
|
||||
createDefaultProfile,
|
||||
} from '../src/shared/profileState'
|
||||
import { calculateResizedWindowBounds } from '../src/shared/windowResize'
|
||||
import { createDefaultTheme, resolveTheme } from '../src/shared/themeState'
|
||||
import { usePerformanceStore } from '../src/renderer/stores/performanceStore'
|
||||
import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft'
|
||||
import {
|
||||
moveDockedScopeOrder,
|
||||
useSettingsStore,
|
||||
@@ -22,7 +24,8 @@ import {
|
||||
inputGainDbToLinear,
|
||||
} from '../src/renderer/audio/inputGain'
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope'
|
||||
import type { ScopePopoutStateMap } from '../src/types/popout'
|
||||
import type { ScopePopoutStateMap, WindowBounds } from '../src/types/popout'
|
||||
import { RESIZE_DIRECTIONS } from '../src/types/windowResize'
|
||||
import { ScopePopoutDataSource } from '../src/renderer/popouts/ScopePopoutDataSource'
|
||||
import {
|
||||
VUMeterBallistics,
|
||||
@@ -36,6 +39,11 @@ import {
|
||||
NativeVisualizerTransport,
|
||||
type NativeVisualizerTransportBridge,
|
||||
} from '../src/renderer/audio/NativeVisualizerTransport'
|
||||
import {
|
||||
DEFAULT_PROFILE_ID,
|
||||
DEFAULT_PROFILE_NAME,
|
||||
type Profile,
|
||||
} from '../src/types/profile'
|
||||
|
||||
type WindowWithRaf = typeof globalThis & Pick<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>
|
||||
type WindowWithTimers = typeof globalThis & Pick<Window, 'setTimeout' | 'clearTimeout'> & {
|
||||
@@ -43,6 +51,9 @@ type WindowWithTimers = typeof globalThis & Pick<Window, 'setTimeout' | 'clearTi
|
||||
platform: string
|
||||
}
|
||||
}
|
||||
type GlobalWithStorage = typeof globalThis & {
|
||||
localStorage?: Storage
|
||||
}
|
||||
|
||||
function installFakeAnimationFrame(): {
|
||||
pendingCount: () => number
|
||||
@@ -196,6 +207,112 @@ function createScopePopouts(poppedOutScopes: ScopeKind[] = []): ScopePopoutState
|
||||
}, {} as ScopePopoutStateMap)
|
||||
}
|
||||
|
||||
function installFakeLocalStorage(): {
|
||||
getSetCount: () => number
|
||||
restore: () => void
|
||||
} {
|
||||
const storage = new Map<string, string>()
|
||||
let setCount = 0
|
||||
const globalWithStorage = globalThis as GlobalWithStorage
|
||||
const previousLocalStorage = globalWithStorage.localStorage
|
||||
|
||||
globalWithStorage.localStorage = {
|
||||
getItem(key: string): string | null {
|
||||
return storage.get(key) ?? null
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
setCount += 1
|
||||
storage.set(key, value)
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
storage.delete(key)
|
||||
},
|
||||
clear(): void {
|
||||
storage.clear()
|
||||
},
|
||||
key(index: number): string | null {
|
||||
return [...storage.keys()][index] ?? null
|
||||
},
|
||||
get length(): number {
|
||||
return storage.size
|
||||
},
|
||||
} as Storage
|
||||
|
||||
return {
|
||||
getSetCount: () => setCount,
|
||||
restore(): void {
|
||||
if (previousLocalStorage === undefined) {
|
||||
delete globalWithStorage.localStorage
|
||||
return
|
||||
}
|
||||
|
||||
globalWithStorage.localStorage = previousLocalStorage
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeElectronWindow(): {
|
||||
restore: () => void
|
||||
} {
|
||||
const globalWithWindow = globalThis as typeof globalThis & { window?: WindowWithTimers }
|
||||
const previousWindow = globalWithWindow.window
|
||||
|
||||
globalWithWindow.window = {
|
||||
...globalThis,
|
||||
electronAPI: { platform: 'darwin' },
|
||||
} as WindowWithTimers
|
||||
|
||||
return {
|
||||
restore(): void {
|
||||
if (previousWindow === undefined) {
|
||||
delete globalWithWindow.window
|
||||
return
|
||||
}
|
||||
|
||||
globalWithWindow.window = previousWindow
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function seedProfileDraftState(profile: Profile): void {
|
||||
useSettingsStore.setState({
|
||||
themeId: profile.themeId,
|
||||
scopeOrder: [...profile.scopeOrder],
|
||||
hiddenScopes: new Set(profile.hiddenScopes),
|
||||
widthWeights: { ...profile.widthWeights },
|
||||
scopeSettings: JSON.parse(JSON.stringify(profile.scopeSettings)) as Profile['scopeSettings'],
|
||||
scopePopouts: JSON.parse(JSON.stringify(profile.scopePopouts)) as Profile['scopePopouts'],
|
||||
windowBounds: profile.windowBounds,
|
||||
profiles: {
|
||||
[DEFAULT_PROFILE_ID]: JSON.parse(JSON.stringify(profile)) as Profile,
|
||||
},
|
||||
activeProfileId: DEFAULT_PROFILE_ID,
|
||||
savedProfileBaseline: JSON.parse(JSON.stringify(profile)) as Profile,
|
||||
hasUnsavedProfileChanges: false,
|
||||
})
|
||||
}
|
||||
|
||||
function resizeBounds(
|
||||
edge: (typeof RESIZE_DIRECTIONS)[number],
|
||||
cursor: { x: number; y: number },
|
||||
minWidth = 120,
|
||||
minHeight = 90,
|
||||
): WindowBounds {
|
||||
return calculateResizedWindowBounds({
|
||||
edge,
|
||||
startBounds: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 180,
|
||||
},
|
||||
startCursor: { x: 0, y: 0 },
|
||||
cursor,
|
||||
minWidth,
|
||||
minHeight,
|
||||
})
|
||||
}
|
||||
|
||||
function createFakeTransportBridge(): {
|
||||
bridge: NativeVisualizerTransportBridge
|
||||
calls: {
|
||||
@@ -282,6 +399,51 @@ test('color helpers fall back predictably for invalid values', () => {
|
||||
assert.deepEqual(resolveColorToRgb('rgb(4, 5, 6)'), { r: 4, g: 5, b: 6 })
|
||||
})
|
||||
|
||||
test('calculateResizedWindowBounds supports every resize direction', () => {
|
||||
const expectedByDirection: Record<(typeof RESIZE_DIRECTIONS)[number], WindowBounds> = {
|
||||
n: { x: 100, y: 220, width: 300, height: 160 },
|
||||
s: { x: 100, y: 200, width: 300, height: 200 },
|
||||
e: { x: 100, y: 200, width: 340, height: 180 },
|
||||
w: { x: 140, y: 200, width: 260, height: 180 },
|
||||
ne: { x: 100, y: 220, width: 340, height: 160 },
|
||||
nw: { x: 140, y: 220, width: 260, height: 160 },
|
||||
se: { x: 100, y: 200, width: 340, height: 200 },
|
||||
sw: { x: 140, y: 200, width: 260, height: 200 },
|
||||
}
|
||||
|
||||
for (const edge of RESIZE_DIRECTIONS) {
|
||||
assert.deepEqual(
|
||||
resizeBounds(edge, { x: 40, y: 20 }),
|
||||
expectedByDirection[edge],
|
||||
`expected ${edge} resize bounds to match`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('calculateResizedWindowBounds clamps east and south resizes to minimum size', () => {
|
||||
assert.deepEqual(
|
||||
resizeBounds('e', { x: -220, y: 0 }, 140, 90),
|
||||
{ x: 100, y: 200, width: 140, height: 180 },
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
resizeBounds('s', { x: 0, y: -140 }, 120, 100),
|
||||
{ x: 100, y: 200, width: 300, height: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
test('calculateResizedWindowBounds keeps north and west edges anchored when clamped', () => {
|
||||
assert.deepEqual(
|
||||
resizeBounds('w', { x: 220, y: 0 }, 140, 90),
|
||||
{ x: 260, y: 200, width: 140, height: 180 },
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
resizeBounds('nw', { x: 220, y: 140 }, 140, 100),
|
||||
{ x: 260, y: 280, width: 140, height: 100 },
|
||||
)
|
||||
})
|
||||
|
||||
test('inputGainDbToLinear converts dB offsets to expected linear gain values', () => {
|
||||
assert.equal(inputGainDbToLinear(0), 1)
|
||||
assertAlmostEqual(inputGainDbToLinear(6), 1.9952623149688795, 1e-12, '+6 dB gain')
|
||||
@@ -594,6 +756,114 @@ test('applying a profile snapshot does not change the machine-local frame target
|
||||
}
|
||||
})
|
||||
|
||||
test('profile draft comparisons return to clean after reverting a change', () => {
|
||||
const baselineProfile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
||||
baselineProfile.themeId = 'theme_default'
|
||||
baselineProfile.windowBounds = { x: 24, y: 48, width: 900, height: 180 }
|
||||
baselineProfile.scopePopouts.spectrum = {
|
||||
poppedOut: true,
|
||||
windowBounds: { x: 160, y: 90, width: 420, height: 240 },
|
||||
}
|
||||
|
||||
const baselineDraft = buildProfileDraft({
|
||||
themeId: baselineProfile.themeId,
|
||||
scopeOrder: baselineProfile.scopeOrder,
|
||||
hiddenScopes: baselineProfile.hiddenScopes,
|
||||
widthWeights: baselineProfile.widthWeights,
|
||||
scopeSettings: baselineProfile.scopeSettings,
|
||||
scopePopouts: baselineProfile.scopePopouts,
|
||||
windowBounds: baselineProfile.windowBounds,
|
||||
}, baselineProfile.name)
|
||||
|
||||
const changedDraft = buildProfileDraft({
|
||||
themeId: baselineProfile.themeId,
|
||||
scopeOrder: baselineProfile.scopeOrder,
|
||||
hiddenScopes: baselineProfile.hiddenScopes,
|
||||
widthWeights: baselineProfile.widthWeights,
|
||||
scopeSettings: {
|
||||
...baselineProfile.scopeSettings,
|
||||
waveform: {
|
||||
...baselineProfile.scopeSettings.waveform,
|
||||
gainDb: baselineProfile.scopeSettings.waveform.gainDb + 3,
|
||||
},
|
||||
},
|
||||
scopePopouts: baselineProfile.scopePopouts,
|
||||
windowBounds: baselineProfile.windowBounds,
|
||||
}, baselineProfile.name)
|
||||
|
||||
const revertedDraft = buildProfileDraft({
|
||||
themeId: baselineProfile.themeId,
|
||||
scopeOrder: baselineProfile.scopeOrder,
|
||||
hiddenScopes: baselineProfile.hiddenScopes,
|
||||
widthWeights: baselineProfile.widthWeights,
|
||||
scopeSettings: baselineProfile.scopeSettings,
|
||||
scopePopouts: baselineProfile.scopePopouts,
|
||||
windowBounds: baselineProfile.windowBounds,
|
||||
}, baselineProfile.name)
|
||||
|
||||
assert.equal(profilesMatch(baselineDraft, changedDraft), false)
|
||||
assert.equal(profilesMatch(baselineDraft, revertedDraft), true)
|
||||
})
|
||||
|
||||
test('main-window bounds updates stay in memory in Electron mode until save', () => {
|
||||
const previousSettingsState = useSettingsStore.getState()
|
||||
const fakeStorage = installFakeLocalStorage()
|
||||
const fakeWindow = installFakeElectronWindow()
|
||||
|
||||
try {
|
||||
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
||||
profile.themeId = 'theme_default'
|
||||
seedProfileDraftState(profile)
|
||||
|
||||
useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
|
||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||
assert.equal(fakeStorage.getSetCount(), 0)
|
||||
|
||||
useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 })
|
||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true)
|
||||
assert.equal(fakeStorage.getSetCount(), 0)
|
||||
|
||||
useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
|
||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||
assert.equal(fakeStorage.getSetCount(), 0)
|
||||
} finally {
|
||||
useSettingsStore.setState(previousSettingsState)
|
||||
fakeWindow.restore()
|
||||
fakeStorage.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('popout bounds updates stay in memory in Electron mode until save', () => {
|
||||
const previousSettingsState = useSettingsStore.getState()
|
||||
const fakeStorage = installFakeLocalStorage()
|
||||
const fakeWindow = installFakeElectronWindow()
|
||||
|
||||
try {
|
||||
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
||||
profile.themeId = 'theme_default'
|
||||
profile.scopePopouts.spectrum = {
|
||||
poppedOut: true,
|
||||
}
|
||||
seedProfileDraftState(profile)
|
||||
|
||||
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
|
||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||
assert.equal(fakeStorage.getSetCount(), 0)
|
||||
|
||||
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 180, y: 60, width: 420, height: 240 })
|
||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true)
|
||||
assert.equal(fakeStorage.getSetCount(), 0)
|
||||
|
||||
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
|
||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||
assert.equal(fakeStorage.getSetCount(), 0)
|
||||
} finally {
|
||||
useSettingsStore.setState(previousSettingsState)
|
||||
fakeWindow.restore()
|
||||
fakeStorage.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('moveDockedScopeOrder is a no-op at the docked boundaries', () => {
|
||||
const initialOrder = [...SCOPE_KINDS]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user