multiple fixes to profile saving and windows specific UI bugs

This commit is contained in:
Boof2015
2026-03-29 16:38:14 -04:00
parent 87f1516b4e
commit 436c45fd3a
16 changed files with 1289 additions and 167 deletions
+202 -39
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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>
)
}
+7 -6
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+11 -4
View File
@@ -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>
)
}
+10
View File
@@ -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>
)
}
+44
View File
@@ -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))
}
+279 -93
View File
@@ -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
View File
@@ -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;
+75
View File
@@ -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),
}
}
+12
View File
@@ -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',
]
+271 -1
View File
@@ -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]