From 3a8a6dae75e997cb69fe364c2e6135236791ccdf Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:15:56 -0400 Subject: [PATCH] update theming system, minor qol improvements --- package.json | 1 + scripts/run-window-state-tests.mjs | 44 + src/main/index.ts | 187 ++-- src/main/themeLibrary.ts | 25 +- src/main/windowStateStore.ts | 83 ++ src/renderer/components/BottomBar.tsx | 72 -- src/renderer/components/ScopeModule.tsx | 52 +- src/renderer/components/Toolbar.tsx | 51 +- src/renderer/popouts/ScopePopoutWindow.tsx | 36 +- src/renderer/styles/globals.css | 217 ++-- src/renderer/utils/color.ts | 87 ++ src/renderer/visualizers/LUFSMeter.ts | 12 +- src/renderer/visualizers/Oscilloscope.ts | 15 +- src/renderer/visualizers/Spectrogram.ts | 11 + src/renderer/visualizers/VUMeter.ts | 14 +- src/renderer/visualizers/Vectorscope.ts | 23 +- src/renderer/visualizers/Waveform.ts | 8 +- src/renderer/visualizers/vectorscopeGrids.ts | 45 +- src/shared/themeState.ts | 1016 ++++++++++++------ src/shared/windowState.ts | 37 + src/types/theme.ts | 197 +++- src/types/windowState.ts | 11 + test/renderer-helpers.test.ts | 56 +- test/theme-library.test.ts | 143 ++- test/window-state-store.test.ts | 135 +++ 25 files changed, 1838 insertions(+), 740 deletions(-) create mode 100644 scripts/run-window-state-tests.mjs create mode 100644 src/main/windowStateStore.ts create mode 100644 src/shared/windowState.ts create mode 100644 src/types/windowState.ts create mode 100644 test/window-state-store.test.ts diff --git a/package.json b/package.json index 9c1c2ba..4e0dba4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:astra": "node scripts/run-astra-integration-tests.mjs", "test:profiles": "node scripts/run-profile-library-tests.mjs", "test:themes": "node scripts/run-theme-library-tests.mjs", + "test:window-state": "node scripts/run-window-state-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "build:native": "cd native && node-gyp rebuild", "rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"", diff --git a/scripts/run-window-state-tests.mjs b/scripts/run-window-state-tests.mjs new file mode 100644 index 0000000..7090363 --- /dev/null +++ b/scripts/run-window-state-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-window-state-tests-')) +const bundledTestPath = join(tempDir, 'window-state-store.test.mjs') +const entryPoint = join(rootDir, 'test', 'window-state-store.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index c45876e..fe84959 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,7 @@ import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' import { AstraIntegrationService } from './services/astraIntegration' import { FileBackedThemeLibrary } from './themeLibrary' +import { FileBackedWindowStateStore } from './windowStateStore' let mainWindow: BrowserWindow | null = null let moveInterval: ReturnType | null = null @@ -55,6 +56,7 @@ const pendingThemeOpenPaths: string[] = [] let profileLibrary: FileBackedProfileLibrary | null = null let themeLibrary: FileBackedThemeLibrary | null = null let astraIntegrationService: AstraIntegrationService | null = null +let windowStateStore: FileBackedWindowStateStore | null = null const WINDOW_DEFAULTS = { width: 900, @@ -93,6 +95,16 @@ function getThemeLibrary(): FileBackedThemeLibrary { return themeLibrary } +function getWindowStateStore(): FileBackedWindowStateStore { + if (!windowStateStore) { + windowStateStore = new FileBackedWindowStateStore( + join(app.getPath('userData'), 'window-state.json'), + ) + } + + return windowStateStore +} + function broadcastAstraState(state: AstraIntegrationState): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed() || window.webContents.isDestroyed()) continue @@ -242,6 +254,27 @@ function isMainRendererWindow(window: BrowserWindow | null): boolean { return window !== null && window === mainWindow } +function getScopeKindForWindow(window: BrowserWindow | null): ScopeKind | null { + if (!window) return null + + for (const [kind, popoutWindow] of scopePopoutWindows) { + if (popoutWindow === window) { + return kind + } + } + + return null +} + +function describeWindow(window: BrowserWindow): string { + if (isMainRendererWindow(window)) { + return 'main window' + } + + const kind = getScopeKindForWindow(window) + return kind ? `${kind} popout` : `window ${window.id}` +} + function getBaseMinHeight(window: BrowserWindow): number { return isMainRendererWindow(window) ? WINDOW_DEFAULTS.minHeight : POPOUT_DEFAULTS.minHeight } @@ -496,6 +529,84 @@ function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds { } } +function emitAlwaysOnTopChanged(window: BrowserWindow, next: boolean): void { + if (window.isDestroyed() || window.webContents.isDestroyed()) { + return + } + + window.webContents.send('window:always-on-top-changed', next) +} + +async function persistAlwaysOnTopPreference(window: BrowserWindow, next: boolean): Promise { + if (isMainRendererWindow(window)) { + await getWindowStateStore().setMainAlwaysOnTop(next) + return + } + + const kind = getScopeKindForWindow(window) + if (kind) { + await getWindowStateStore().setPopoutAlwaysOnTop(kind, next) + } +} + +function setWindowAlwaysOnTop(window: BrowserWindow, next: boolean): void { + if (window.isDestroyed()) { + return + } + + window.setAlwaysOnTop(next) + emitAlwaysOnTopChanged(window, next) + void persistAlwaysOnTopPreference(window, next).catch((error) => { + console.warn(`Could not persist always-on-top state for ${describeWindow(window)}:`, error) + }) +} + +function attachWindowShortcuts(window: BrowserWindow): void { + const scopeKeys = new Map([ + ['1', 0], + ['2', 1], + ['3', 2], + ['4', 3], + ['5', 4], + ['6', 5], + ['7', 6], + ['8', 7], + ]) + + window.webContents.on('before-input-event', (_event, input: Electron.Input) => { + if (input.type !== 'keyDown') { + return + } + + const isMainWindow = isMainRendererWindow(window) + + if (!input.alt && !input.control && !input.meta && !input.shift) { + if (isMainWindow) { + const scopeIndex = scopeKeys.get(input.key) + if (scopeIndex !== undefined) { + window.webContents.send('shortcut:toggle-scope', scopeIndex) + return + } + + if (input.key === ' ') { + window.webContents.send('shortcut:toggle-capture') + return + } + } + + if (input.key === 't') { + setWindowAlwaysOnTop(window, !window.isAlwaysOnTop()) + } + + return + } + + if (isMainWindow && input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) { + window.webContents.send('shortcut:toggle-settings') + } + }) +} + function loadRendererTarget(window: BrowserWindow, query: Record): void { if (process.env.ELECTRON_RENDERER_URL) { const url = new URL(process.env.ELECTRON_RENDERER_URL) @@ -513,7 +624,7 @@ function createMainWindow(): void { mainWindow = new BrowserWindow({ ...WINDOW_DEFAULTS, ...getFramelessWindowChromeOptions(), - alwaysOnTop: true, + alwaysOnTop: getWindowStateStore().getMainAlwaysOnTop(), autoHideMenuBar: true, resizable: true, maximizable: true, @@ -576,16 +687,10 @@ function createMainWindow(): void { scheduleMainWindowBoundsSave(mainWindow) }) + attachWindowShortcuts(mainWindow) loadRendererTarget(mainWindow, { window: 'main' }) } -function setAllWindowsAlwaysOnTop(next: boolean): void { - mainWindow?.setAlwaysOnTop(next) - for (const window of scopePopoutWindows.values()) { - window.setAlwaysOnTop(next) - } -} - function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void { if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed() || !mainRendererReady) return @@ -664,7 +769,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro skipTaskbar: true, autoHideMenuBar: true, title: `Prism ${kind}`, - alwaysOnTop: mainWindow.isAlwaysOnTop(), + alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind), show: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), @@ -713,6 +818,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow)) popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow)) + attachWindowShortcuts(popoutWindow) loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind }) return popoutWindow } @@ -888,19 +994,11 @@ function setupIPC(): void { const targetWindow = getWindowFromSender(event.sender) if (!targetWindow) return - const current = targetWindow.isAlwaysOnTop() - const next = !current - if (mainWindow && targetWindow === mainWindow) { - setAllWindowsAlwaysOnTop(next) - mainWindow?.webContents.send('window:always-on-top-changed', next) - return - } - - targetWindow.setAlwaysOnTop(next) + setWindowAlwaysOnTop(targetWindow, !targetWindow.isAlwaysOnTop()) }) ipcMain.handle('window:is-always-on-top', (event) => { - return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true + return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false }) ipcMain.handle('audio:get-desktop-sources', async () => { @@ -1216,64 +1314,17 @@ function setupIPC(): void { }) } -function setupShortcuts(): void { - if (!mainWindow) return - - const shortcutWindow = mainWindow - const scopeKeys = new Map([ - ['1', 0], - ['2', 1], - ['3', 2], - ['4', 3], - ['5', 4], - ['6', 5], - ['7', 6], - ['8', 7], - ]) - const beforeInputHandler = (_event: Electron.Event, input: Electron.Input) => { - if (input.type !== 'keyDown') { - return - } - - if (!input.alt && !input.control && !input.meta && !input.shift) { - const scopeIndex = scopeKeys.get(input.key) - if (scopeIndex !== undefined) { - shortcutWindow.webContents.send('shortcut:toggle-scope', scopeIndex) - return - } - - if (input.key === 't') { - const next = !shortcutWindow.isAlwaysOnTop() - setAllWindowsAlwaysOnTop(next) - shortcutWindow.webContents.send('window:always-on-top-changed', next) - return - } - - if (input.key === ' ') { - shortcutWindow.webContents.send('shortcut:toggle-capture') - return - } - } - - if (input.key === ',' && input.meta && !input.alt && !input.control && !input.shift) { - shortcutWindow.webContents.send('shortcut:toggle-settings') - } - } - - shortcutWindow.webContents.on('before-input-event', beforeInputHandler) -} - const hasSingleInstanceLock = app.requestSingleInstanceLock() if (!hasSingleInstanceLock) { app.quit() } else { - app.whenReady().then(() => { + app.whenReady().then(async () => { setupPermissions() void getAstraIntegrationService().initialize() setupIPC() + await getWindowStateStore().initialize() createMainWindow() - setupShortcuts() queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) queueThemeOpenPaths(extractThemePathsFromArgv(process.argv)) void processPendingProfileOpenPaths() diff --git a/src/main/themeLibrary.ts b/src/main/themeLibrary.ts index 74a8130..d3c56ab 100644 --- a/src/main/themeLibrary.ts +++ b/src/main/themeLibrary.ts @@ -167,10 +167,7 @@ export class FileBackedThemeLibrary { entries = await this.readManagedEntries() } - if (!entries.some((entry) => entry.id === DEFAULT_THEME_ID)) { - await this.writeManagedTheme(entries, DEFAULT_THEME_ID, createDefaultTheme()) - entries = await this.readManagedEntries() - } + entries = await this.syncBundledThemes(entries) await this.ensureTemplateFile() @@ -188,6 +185,26 @@ export class FileBackedThemeLibrary { } } + private async syncBundledThemes(entries: ManagedThemeEntry[]): Promise { + let nextEntries = entries + + for (const theme of createBundledThemes()) { + const existingEntry = nextEntries.find((entry) => entry.id === theme.id) ?? null + const shouldWrite = !existingEntry || serializeThemeFile(existingEntry.theme) !== serializeThemeFile(theme) + if (!shouldWrite) continue + + await this.writeManagedTheme(nextEntries, theme.id, theme, existingEntry?.path) + nextEntries = await this.readManagedEntries() + } + + if (!nextEntries.some((entry) => entry.id === DEFAULT_THEME_ID)) { + await this.writeManagedTheme(nextEntries, DEFAULT_THEME_ID, createDefaultTheme()) + nextEntries = await this.readManagedEntries() + } + + return nextEntries + } + private async ensureTemplateFile(): Promise { const targetPath = resolve(join(this.themesDir, TEMPLATE_THEME_FILE_NAME)) if (await this.pathExists(targetPath)) { diff --git a/src/main/windowStateStore.ts b/src/main/windowStateStore.ts new file mode 100644 index 0000000..c1ebb48 --- /dev/null +++ b/src/main/windowStateStore.ts @@ -0,0 +1,83 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { createEmptyWindowLocalState, normalizeWindowLocalState } from '../shared/windowState' +import type { ScopeKind } from '../types/scope' +import type { PrismWindowLocalStateV1 } from '../types/windowState' + +export class FileBackedWindowStateStore { + private state: PrismWindowLocalStateV1 = createEmptyWindowLocalState() + private initialized = false + private writeChain: Promise = Promise.resolve() + + constructor(private readonly localStatePath: string) {} + + async initialize(): Promise { + if (this.initialized) return + this.state = await this.readLocalState() + this.initialized = true + } + + getMainAlwaysOnTop(): boolean { + return this.state.mainAlwaysOnTop + } + + getPopoutAlwaysOnTop(kind: ScopeKind): boolean { + return this.state.popoutAlwaysOnTop[kind] === true + } + + async setMainAlwaysOnTop(alwaysOnTop: boolean): Promise { + await this.ensureInitialized() + this.state = normalizeWindowLocalState({ + ...this.state, + mainAlwaysOnTop: alwaysOnTop, + popoutAlwaysOnTop: { ...this.state.popoutAlwaysOnTop }, + }) + await this.persistState() + } + + async setPopoutAlwaysOnTop(kind: ScopeKind, alwaysOnTop: boolean): Promise { + await this.ensureInitialized() + + const popoutAlwaysOnTop = { ...this.state.popoutAlwaysOnTop } + if (alwaysOnTop) { + popoutAlwaysOnTop[kind] = true + } else { + delete popoutAlwaysOnTop[kind] + } + + this.state = normalizeWindowLocalState({ + ...this.state, + popoutAlwaysOnTop, + }) + await this.persistState() + } + + private async ensureInitialized(): Promise { + if (!this.initialized) { + await this.initialize() + } + } + + private async readLocalState(): Promise { + try { + const raw = await readFile(this.localStatePath, 'utf8') + return normalizeWindowLocalState(JSON.parse(raw) as unknown) + } catch { + return createEmptyWindowLocalState() + } + } + + private async persistState(): Promise { + const state = normalizeWindowLocalState(this.state) + const payload = `${JSON.stringify(state, null, 2)}\n` + + this.writeChain = this.writeChain + .catch(() => {}) + .then(async () => { + await mkdir(dirname(this.localStatePath), { recursive: true }) + await writeFile(this.localStatePath, payload, 'utf8') + }) + + await this.writeChain + } +} diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 6892ea3..69ce8ed 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -47,16 +47,11 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const frameTarget = usePerformanceStore((s) => s.frameTarget) const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) - const themeId = useSettingsStore((s) => s.themeId) const setThemeId = useSettingsStore((s) => s.setThemeId) const { themes, activeThemeId, loadTheme, - renameTheme, - deleteTheme, - reloadThemes, - importThemeFromDialog, showThemesFolder, } = useThemeStore() const astraState = useAstraStore((s) => s.integrationState) @@ -161,26 +156,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): setThemeId(value) } - const handleRenameTheme = async (): Promise => { - if (!activeThemeId || activeThemeId === 'theme_default') return - const activeTheme = themes[activeThemeId] - if (!activeTheme) return - - const nextName = window.prompt('Rename theme', activeTheme.name)?.trim() - if (!nextName) return - await renameTheme(activeThemeId, nextName) - } - - const handleDeleteTheme = async (): Promise => { - if (!activeThemeId || activeThemeId === 'theme_default') return - const activeTheme = themes[activeThemeId] - if (!activeTheme) return - if (!window.confirm(`Delete "${activeTheme.name}"?`)) return - - await deleteTheme(activeThemeId) - setThemeId(useThemeStore.getState().activeThemeId) - } - const handleSaveAstraConfig = async (): Promise => { const nextConfig: AstraIntegrationConfig = { baseUrl: astraBaseUrlInput, @@ -264,27 +239,6 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): ))} - - - {activeThemeId && activeThemeId !== 'theme_default' ? ( - - ) : null} - {activeThemeId && activeThemeId !== 'theme_default' ? ( - - ) : null} -
- - {themeId ? 'Saved With Profile' : 'Not Linked'} -
diff --git a/src/renderer/components/ScopeModule.tsx b/src/renderer/components/ScopeModule.tsx index 57f9fa3..53bcc63 100644 --- a/src/renderer/components/ScopeModule.tsx +++ b/src/renderer/components/ScopeModule.tsx @@ -71,12 +71,13 @@ export function scopeSettingsToOptions( const s = settings as ScopeSettings['spectrum'] const t = theme as ResolvedSpectrumTheme return { - lineColor: t.primary, - secondaryLineColor: t.secondary, + lineColor: t.line, + secondaryLineColor: t.sideLine, gradientColors: t.fillGradient, heatColors: t.heatColors, backgroundColor: t.background, gridColor: t.guides, + labelColor: t.labels, fftSize: s.fftSize, tiltDbPerOctave: s.tiltDbPerOctave, heatmapFill: s.heatmap, @@ -91,9 +92,10 @@ export function scopeSettingsToOptions( const s = settings as ScopeSettings['oscilloscope'] const t = theme as ResolvedOscilloscopeTheme return { - lineColor: t.primary, + lineColor: t.line, backgroundColor: t.background, - gridColor: t.guides, + gridMajorColor: t.guides, + gridMinorColor: t.guidesSecondary, underfillColor: t.fill, pitchLock: s.pitchLock, underfillEnabled: s.underfillEnabled, @@ -105,13 +107,15 @@ export function scopeSettingsToOptions( const s = settings as ScopeSettings['vectorscope'] const t = theme as ResolvedVectorscopeTheme return { - lineColor: t.primary, + lineColor: t.trace, backgroundColor: t.background, - gridColor: t.guides, + gridMajorColor: t.guides, + gridMinorColor: t.guidesSecondary, + labelColor: t.labels, bandColors: { - low: t.lowBand, - mid: t.midBand, - high: t.highBand, + low: t.bandLow, + mid: t.bandMid, + high: t.bandHigh, }, mode: s.mode, multiband: s.multiband, @@ -124,8 +128,9 @@ export function scopeSettingsToOptions( const s = settings as ScopeSettings['spectrogram'] const t = theme as ResolvedSpectrogramTheme return { - lineColor: t.primary, + lineColor: t.mono, heatColors: t.heatColors, + backgroundColor: t.background, fftSize: s.fftSize, scrollSpeed: s.scrollSpeed, clarityMode: s.clarityMode, @@ -137,11 +142,13 @@ export function scopeSettingsToOptions( const s = settings as ScopeSettings['vumeter'] const t = theme as ResolvedVUMeterTheme return { - lineColor: t.primary, + backgroundColor: t.background, + lineColor: t.level, + trackColor: t.track, peakColor: t.peak, clipColor: t.clip, - scaleColor: t.guides, - labelColor: t.text, + scaleColor: t.scale, + labelColor: t.labels, mode: s.mode, orientation: s.orientation, } @@ -150,10 +157,12 @@ export function scopeSettingsToOptions( const s = settings as ScopeSettings['lufsmeter'] const t = theme as ResolvedLUFSMeterTheme return { - lineColor: t.primary, + backgroundColor: t.background, + lineColor: t.level, + trackColor: t.track, targetColor: t.target, - scaleColor: t.guides, - labelColor: t.text, + scaleColor: t.scale, + labelColor: t.labels, mode: s.mode, } } @@ -161,13 +170,14 @@ export function scopeSettingsToOptions( const s = settings as ScopeSettings['waveform'] const t = theme as ResolvedWaveformTheme return { - lineColor: t.primary, + backgroundColor: t.background, + lineColor: t.line, gridMajorColor: t.guides, - gridMinorColor: t.guides, + gridMinorColor: t.guidesSecondary, bandColors: { - low: t.lowBand, - mid: t.midBand, - high: t.highBand, + low: t.bandLow, + mid: t.bandMid, + high: t.bandHigh, }, mode: s.mode, scrollSpeed: s.scrollSpeed, diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index a0f33f6..7a41f8b 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -1,11 +1,19 @@ -import { useState, useEffect, useCallback, useRef, type CSSProperties, type JSX, type PointerEvent as ReactPointerEvent } from 'react' +import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react' import { useSettingsStore } from '../stores/settingsStore' function SettingsIcon(): JSX.Element { return ( ) } @@ -70,6 +78,11 @@ interface ToolbarProps { const DEFAULT_PROFILE_ID = 'profile_default' +function isToolbarInteractiveTarget(target: EventTarget | null): boolean { + return target instanceof Element + && target.closest('button, .toolbar__reposition-menu') !== null +} + function getErrorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message @@ -88,13 +101,13 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile) const importProfileFromDialog = useSettingsStore((s) => s.importProfileFromDialog) const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder) - const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true) + const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false) const [showReposition, setShowReposition] = useState(false) const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false) const profileButtonRef = useRef(null) useEffect(() => { - window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop) + void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop) const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop) return unsubscribe }, []) @@ -284,10 +297,30 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): window.electronAPI.stopWindowMove() }, []) + const handleToolbarDragStart = useCallback((event: ReactPointerEvent): void => { + if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + window.electronAPI.startWindowMove() + }, []) + + const handleToolbarDragEnd = useCallback((event: ReactPointerEvent): void => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + window.electronAPI.stopWindowMove() + }, []) + const activeProfile = activeProfileId ? profiles[activeProfileId] : null return ( -
+
-
+
diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index 60651c6..5d136be 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -21,8 +21,24 @@ function PopInIcon(): JSX.Element { function SettingsIcon(): JSX.Element { return ( + ) +} + +function PinIcon(): JSX.Element { + return ( + ) } @@ -49,12 +65,19 @@ const defaultTheme = resolveTheme(createDefaultTheme()) export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element { const [snapshot, setSnapshot] = useState | null>(null) + const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false) const [miniSettingsOpen, setMiniSettingsOpen] = useState(false) const prevMiniSettingsOpenRef = useRef(false) const frameTarget = usePerformanceStore((s) => s.frameTarget) const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), []) const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind]) + useEffect(() => { + void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop) + const unsubscribe = window.electronAPI.onAlwaysOnTopChanged(setIsAlwaysOnTop) + return unsubscribe + }, []) + useEffect(() => { frameScheduler.setFrameTarget(frameTarget) }, [frameScheduler, frameTarget]) @@ -191,6 +214,15 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
+