diff --git a/src/main/index.ts b/src/main/index.ts index 489f01f..f5122f9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | null = null let moveStartCursor: { x: number; y: number } | null = null let moveStartPosition: number[] | null = null +let resizeInterval: ReturnType | 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 | null = null +let mainRendererReady = false +let allowMainWindowClose = false +let mainWindowClosePending = false const scopePopoutWindows = new Map() const scopePopoutCloseAllowed = new Set() @@ -133,27 +143,15 @@ function getErrorMessage(error: unknown, fallback: string): string { async function processPendingProfileOpenPaths(): Promise { 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 { @@ -182,7 +180,7 @@ async function processPendingThemeOpenPaths(): Promise { } 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 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 53645df..b11d9f5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, 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, renameProfile: (id: string, name: string) => ipcRenderer.invoke('profiles:rename', id, name) as Promise, importProfileDialog: () => ipcRenderer.invoke('profiles:import-dialog') as Promise, + importProfileFromPath: (path: string) => ipcRenderer.invoke('profiles:import-path', path) as Promise, + promptUnsavedProfileChanges: (profileName: string | null) => { + return ipcRenderer.invoke('profiles:prompt-unsaved', profileName) as Promise<'save' | 'discard' | 'cancel'> + }, revealProfilesFolder: () => ipcRenderer.invoke('profiles:reveal-folder') as Promise, migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => ipcRenderer.invoke('profiles:migrate-legacy', payload) as Promise, getThemeSnapshot: () => ipcRenderer.invoke('themes:get-snapshot') as Promise, @@ -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) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f888b55..53648f0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 | 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 { )} + + ) } diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 2e4cedd..07e49eb 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -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 = { spectrum: 'Spectrum', @@ -198,19 +199,19 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
Theme
- +
- +
diff --git a/src/renderer/components/ScopeSettingsSection.tsx b/src/renderer/components/ScopeSettingsSection.tsx index 4f26767..df8d932 100644 --- a/src/renderer/components/ScopeSettingsSection.tsx +++ b/src/renderer/components/ScopeSettingsSection.tsx @@ -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 ( ) } diff --git a/src/renderer/components/ThemedSelect.tsx b/src/renderer/components/ThemedSelect.tsx new file mode 100644 index 0000000..1c07c8e --- /dev/null +++ b/src/renderer/components/ThemedSelect.tsx @@ -0,0 +1,37 @@ +import type { JSX, SelectHTMLAttributes } from 'react' + +function ChevronIcon(): JSX.Element { + return ( + + ) +} + +interface ThemedSelectProps extends SelectHTMLAttributes { + className?: string +} + +export default function ThemedSelect({ + className, + children, + ...props +}: ThemedSelectProps): JSX.Element { + return ( +
+ + +
+ ) +} diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 843774a..a0f33f6 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -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): {activeProfile?.name ?? 'Profiles'} + {hasUnsavedProfileChanges ?
diff --git a/src/renderer/components/WindowResizeOverlay.tsx b/src/renderer/components/WindowResizeOverlay.tsx new file mode 100644 index 0000000..ea6f242 --- /dev/null +++ b/src/renderer/components/WindowResizeOverlay.tsx @@ -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 = { + 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): void => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + window.electronAPI.startWindowResize(direction) + } + }, []) + + const handlePointerEnd = useCallback((event: ReactPointerEvent): void => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + stopResize() + }, [stopResize]) + + if (!isWindows) return null + + return ( +
)} + +
) } diff --git a/src/renderer/stores/profileDraft.ts b/src/renderer/stores/profileDraft.ts new file mode 100644 index 0000000..5cc68c0 --- /dev/null +++ b/src/renderer/stores/profileDraft.ts @@ -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 + widthWeights: Record + 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)) +} diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index e229a81..ebd104b 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -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 scopeSettings: ScopeSettings scopePopouts: ScopePopoutStateMap + windowBounds?: WindowBounds } interface WorkingSettingsState { @@ -43,12 +44,15 @@ interface WorkingSettingsState { widthWeights: Record scopeSettings: ScopeSettings scopePopouts: ScopePopoutStateMap + windowBounds?: WindowBounds } interface SettingsState extends WorkingSettingsState { visibleScopes: () => ScopeKind[] profiles: Record activeProfileId: string | null + savedProfileBaseline: Profile | null + hasUnsavedProfileChanges: boolean initializeProfiles: () => Promise 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 + guardProfileTransition: (transition: () => Promise) => Promise saveProfile: (name: string) => Promise saveProfileAs: (name: string) => Promise updateActiveProfile: () => Promise @@ -66,6 +73,7 @@ interface SettingsState extends WorkingSettingsState { deleteProfile: (id: string) => Promise renameProfile: (id: string, name: string) => Promise importProfileFromDialog: () => Promise + importProfileFromPath: (path: string) => Promise showProfilesFolder: () => Promise } @@ -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 { try { const raw = localStorage.getItem(STORAGE_KEY) if (raw) return JSON.parse(raw) as Partial - } 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): 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, + 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) => 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 { - 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 { + 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((set, get) => ({ @@ -256,6 +412,8 @@ export const useSettingsStore = create((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((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((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((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: (kind: K, settings: Partial) => { 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((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((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((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((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((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() diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 140d516..59386c3 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -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; diff --git a/src/shared/windowResize.ts b/src/shared/windowResize.ts new file mode 100644 index 0000000..8efdc2a --- /dev/null +++ b/src/shared/windowResize.ts @@ -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), + } +} diff --git a/src/types/windowResize.ts b/src/types/windowResize.ts new file mode 100644 index 0000000..4102895 --- /dev/null +++ b/src/types/windowResize.ts @@ -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', +] diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index bb3c4af..89cf05e 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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 type WindowWithTimers = typeof globalThis & Pick & { @@ -43,6 +51,9 @@ type WindowWithTimers = typeof globalThis & Pick number @@ -196,6 +207,112 @@ function createScopePopouts(poppedOutScopes: ScopeKind[] = []): ScopePopoutState }, {} as ScopePopoutStateMap) } +function installFakeLocalStorage(): { + getSetCount: () => number + restore: () => void +} { + const storage = new Map() + 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]