From eccd7227d96827f0b0b842202cc70db323b3dc63 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 19 May 2026 13:44:35 -0400 Subject: [PATCH] native os window snapping --- src/main/index.ts | 26 ++++++------- src/renderer/App.tsx | 2 - .../components/NowPlayingConfigWindow.tsx | 22 +++++------ src/renderer/popouts/ScopePopoutWindow.tsx | 37 +++++++++---------- src/shared/windowCapabilities.ts | 2 +- test/renderer-helpers.test.ts | 25 ++++++++++--- 6 files changed, 58 insertions(+), 56 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 09d4e22..11dd7c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -763,9 +763,9 @@ function isCursorInsideWindow(window: BrowserWindow): boolean { && cursor.y < bounds.y + bounds.height } -function getFramelessWindowChromeOptions(): Pick< +function getSnapCapableFramelessWindowOptions(): Pick< BrowserWindowConstructorOptions, - 'frame' | 'transparent' | 'backgroundColor' | 'roundedCorners' | 'hasShadow' | 'thickFrame' | 'backgroundMaterial' + 'frame' | 'transparent' | 'backgroundColor' | 'roundedCorners' | 'hasShadow' | 'thickFrame' | 'backgroundMaterial' | 'resizable' | 'maximizable' | 'fullscreenable' | 'minimizable' | 'skipTaskbar' > { return { frame: false, @@ -775,10 +775,15 @@ function getFramelessWindowChromeOptions(): Pick< hasShadow: false, ...(process.platform === 'win32' ? { - thickFrame: false, + thickFrame: true, backgroundMaterial: 'none', } : {}), + resizable: true, + maximizable: true, + fullscreenable: true, + minimizable: true, + skipTaskbar: false, } } @@ -988,7 +993,7 @@ async function showCustomDialog(options: DialogOptions): Promise { function createMainWindow(): void { mainWindow = new BrowserWindow({ ...WINDOW_DEFAULTS, - ...getFramelessWindowChromeOptions(), + ...getSnapCapableFramelessWindowOptions(), alwaysOnTop: getWindowStateStore().getMainAlwaysOnTop(), autoHideMenuBar: true, resizable: true, @@ -1192,12 +1197,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro height: bounds.height, minWidth: POPOUT_DEFAULTS.minWidth, minHeight: POPOUT_DEFAULTS.minHeight, - ...getFramelessWindowChromeOptions(), - resizable: true, - fullscreenable: false, - maximizable: false, - minimizable: true, - skipTaskbar: true, + ...getSnapCapableFramelessWindowOptions(), autoHideMenuBar: true, title: `Prism ${SCOPE_LABELS[kind]}`, alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind), @@ -1365,11 +1365,7 @@ function createNowPlayingConfigWindow(): BrowserWindow { height: bounds.height, minWidth: NOW_PLAYING_CONFIG_DEFAULTS.minWidth, minHeight: NOW_PLAYING_CONFIG_DEFAULTS.minHeight, - ...getFramelessWindowChromeOptions(), - resizable: true, - fullscreenable: false, - maximizable: false, - minimizable: true, + ...getSnapCapableFramelessWindowOptions(), autoHideMenuBar: true, title: 'Prism Now Playing', show: false, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fa99707..c78cec6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4,7 +4,6 @@ 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 AppBanner from './components/AppBanner' import { resolveMainWindowSettingsHeight } from './mainWindowSettings' import { useSettingsStore } from './stores/settingsStore' @@ -306,7 +305,6 @@ export default function App(): JSX.Element { - ) } diff --git a/src/renderer/components/NowPlayingConfigWindow.tsx b/src/renderer/components/NowPlayingConfigWindow.tsx index b231181..698749e 100644 --- a/src/renderer/components/NowPlayingConfigWindow.tsx +++ b/src/renderer/components/NowPlayingConfigWindow.tsx @@ -10,7 +10,6 @@ import { } from 'react' import type { NowPlayingProviderDefinition, NowPlayingProviderId, NowPlayingProviderState } from '../../types/nowPlaying' import AppBanner from './AppBanner' -import WindowResizeOverlay from './WindowResizeOverlay' import { useNowPlayingStore } from '../stores/nowPlayingStore' import { useThemeStore } from '../stores/themeStore' import { useUiStore } from '../stores/uiStore' @@ -307,8 +306,8 @@ export default function NowPlayingConfigWindow(): JSX.Element { const [expandedProviderId, setExpandedProviderId] = useState('astra') const [draggedProviderId, setDraggedProviderId] = useState(null) const [dropTargetProviderId, setDropTargetProviderId] = useState(null) - const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions const platform = window.electronAPI.platform + const useWindowManagerDragRegions = getRendererWindowCapabilities().useNativeDragRegions useEffect(() => { let disposed = false @@ -341,14 +340,14 @@ export default function NowPlayingConfigWindow(): JSX.Element { }, [nowPlayingState.configs.astra.baseUrl, nowPlayingState.configs.astra.hasToken]) const handleToolbarDragStart = useCallback((event: ReactPointerEvent): void => { - if (useNativeDragRegions || isToolbarInteractiveTarget(event.target) || event.button !== 0) return + if (useWindowManagerDragRegions || isToolbarInteractiveTarget(event.target) || event.button !== 0) return event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) window.electronAPI.startWindowMove() - }, [useNativeDragRegions]) + }, [useWindowManagerDragRegions]) const handleToolbarDragEnd = useCallback((event: ReactPointerEvent): void => { - if (useNativeDragRegions) { + if (useWindowManagerDragRegions) { return } @@ -356,7 +355,7 @@ export default function NowPlayingConfigWindow(): JSX.Element { event.currentTarget.releasePointerCapture(event.pointerId) } window.electronAPI.stopWindowMove() - }, [useNativeDragRegions]) + }, [useWindowManagerDragRegions]) const handleSaveAstraConfig = useCallback(async (): Promise => { try { @@ -437,11 +436,11 @@ export default function NowPlayingConfigWindow(): JSX.Element {
Now Playing
@@ -699,7 +698,6 @@ export default function NowPlayingConfigWindow(): JSX.Element {
-
) } diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index 7cb14e5..de959c1 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -5,7 +5,6 @@ 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 { useUiStore } from '../stores/uiStore' import { getRendererWindowCapabilities } from '../windowCapabilities' @@ -67,7 +66,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) const setMiniSettingsOpen = useUiStore((s) => s.setSettingsOpen) const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), []) const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind]) - const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions + const useWindowManagerDragRegions = getRendererWindowCapabilities().useNativeDragRegions useEffect(() => { void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop) @@ -148,14 +147,14 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) } const handleDragStart = useCallback((event: ReactPointerEvent): void => { - if (useNativeDragRegions || event.button !== 0) return + if (useWindowManagerDragRegions || event.button !== 0) return event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) window.electronAPI.startWindowMove() - }, [useNativeDragRegions]) + }, [useWindowManagerDragRegions]) const handleDragEnd = useCallback((event: ReactPointerEvent): void => { - if (useNativeDragRegions) { + if (useWindowManagerDragRegions) { return } @@ -163,10 +162,10 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) event.currentTarget.releasePointerCapture(event.pointerId) } window.electronAPI.stopWindowMove() - }, [useNativeDragRegions]) + }, [useWindowManagerDragRegions]) const handleAltDragStart = useCallback((event: ReactMouseEvent): void => { - if (useNativeDragRegions || !event.altKey || event.button !== 0) return + if (useWindowManagerDragRegions || !event.altKey || event.button !== 0) return const target = event.target if (target instanceof Element && target.closest('.scope-popout__drag-handle')) { @@ -175,15 +174,15 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) event.preventDefault() window.electronAPI.startWindowMove() - }, [useNativeDragRegions]) + }, [useWindowManagerDragRegions]) const handleAltDragEnd = useCallback((): void => { - if (useNativeDragRegions) { + if (useWindowManagerDragRegions) { return } window.electronAPI.stopWindowMove() - }, [useNativeDragRegions]) + }, [useWindowManagerDragRegions]) useLayoutEffect(() => { if (miniSettingsOpen && !prevMiniSettingsOpenRef.current) { @@ -210,8 +209,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) onMouseEnter={() => setCursorInsideWindow(true)} onMouseMove={() => setCursorInsideWindow(true)} onMouseLeave={() => setCursorInsideWindow(false)} - onMouseDown={useNativeDragRegions ? undefined : handleAltDragStart} - onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd} + onMouseDown={useWindowManagerDragRegions ? undefined : handleAltDragStart} + onMouseUp={useWindowManagerDragRegions ? undefined : handleAltDragEnd} >
-
+
)} - -
) } diff --git a/src/shared/windowCapabilities.ts b/src/shared/windowCapabilities.ts index 721b582..e778458 100644 --- a/src/shared/windowCapabilities.ts +++ b/src/shared/windowCapabilities.ts @@ -76,7 +76,7 @@ function resolveLinuxDisplayServer( } export function resolveWindowCapabilities(options: WindowCapabilityResolutionOptions): WindowCapabilities { - if (options.platform === 'win32') { + if (options.platform === 'darwin' || options.platform === 'win32') { return { ...DEFAULT_WINDOW_CAPABILITIES, useNativeDragRegions: true, diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 960e42d..52dc139 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -2876,7 +2876,7 @@ test('resolveWindowCapabilities detects X11 sessions on Linux', () => { ) }) -test('resolveWindowCapabilities leaves non-Linux platforms on the full-featured path', () => { +test('resolveWindowCapabilities uses native drag regions on macOS while preserving geometry controls', () => { assert.deepEqual( resolveWindowCapabilities({ platform: 'darwin', @@ -2885,7 +2885,7 @@ test('resolveWindowCapabilities leaves non-Linux platforms on the full-featured }), { displayServer: 'other', - useNativeDragRegions: false, + useNativeDragRegions: true, supportsProgrammaticReposition: true, supportsGeometryPersistence: true, }, @@ -2911,8 +2911,6 @@ test('resolveWindowCapabilities uses native drag regions on Windows while preser test('Wayland window controls use native drag regions and omit unsupported reposition/geometry paths', async () => { const toolbarSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Toolbar.tsx'), 'utf8') const appSource = await readFile(join(process.cwd(), 'src', 'renderer', 'App.tsx'), 'utf8') - const popoutSource = await readFile(join(process.cwd(), 'src', 'renderer', 'popouts', 'ScopePopoutWindow.tsx'), 'utf8') - const nowPlayingSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'NowPlayingConfigWindow.tsx'), 'utf8') const bridgeSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'ScopePopoutBridge.tsx'), 'utf8') const stripSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'Strip.tsx'), 'utf8') const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') @@ -2921,14 +2919,29 @@ test('Wayland window controls use native drag regions and omit unsupported repos assert.match(toolbarSource, /disabled=\{!supportsProgrammaticReposition\}/) assert.match(toolbarSource, /useNativeDragRegions \? 'is-native-drag' : ''/) assert.match(appSource, /onMouseDown=\{useNativeDragRegions \? undefined : handleAltDragStart\}/) - assert.match(popoutSource, /scope-popout__header \$\{useNativeDragRegions \? 'is-native-drag' : ''\}/) - assert.match(nowPlayingSource, /now-playing-config__toolbar \$\{useNativeDragRegions \? 'is-native-drag' : ''\}/) assert.match(bridgeSource, /bounds: supportsGeometryPersistence\s*\?\s*scopePopouts\[kind\]\?\.windowBounds\s*:\s*undefined/) assert.match(stripSource, /if \(!supportsGeometryPersistence\) \{\s*popOutScope\(kind\)/) assert.match(stylesSource, /\.toolbar\.is-native-drag \{/) assert.match(stylesSource, /\.scope-popout__header\.is-native-drag \{/) }) +test('main and detached windows keep frameless Prism chrome while enabling snap-compatible OS window semantics', async () => { + const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8') + const appSource = await readFile(join(process.cwd(), 'src', 'renderer', 'App.tsx'), 'utf8') + const popoutSource = await readFile(join(process.cwd(), 'src', 'renderer', 'popouts', 'ScopePopoutWindow.tsx'), 'utf8') + const nowPlayingSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'NowPlayingConfigWindow.tsx'), 'utf8') + + assert.match(mainSource, /function getSnapCapableFramelessWindowOptions\(\): Pick<[\s\S]*?return \{[\s\S]*?frame: false,[\s\S]*?roundedCorners: false,[\s\S]*?hasShadow: false,[\s\S]*?thickFrame: true,[\s\S]*?resizable: true,[\s\S]*?maximizable: true,[\s\S]*?fullscreenable: true,[\s\S]*?skipTaskbar: false,[\s\S]*?\}/) + assert.match(mainSource, /function createMainWindow\(\): void \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) + assert.match(mainSource, /function createScopePopoutWindow\(kind: ScopeKind, rawBounds\?: WindowBounds\): BrowserWindow \| null \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) + assert.match(mainSource, /function createNowPlayingConfigWindow\(\): BrowserWindow \{[\s\S]*?\.\.\.getSnapCapableFramelessWindowOptions\(\),/) + assert.match(popoutSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/) + assert.match(nowPlayingSource, /useWindowManagerDragRegions = getRendererWindowCapabilities\(\)\.useNativeDragRegions/) + assert.doesNotMatch(appSource, /WindowResizeOverlay/) + assert.doesNotMatch(popoutSource, /WindowResizeOverlay/) + assert.doesNotMatch(nowPlayingSource, /WindowResizeOverlay/) +}) + test('programmatic top/bottom reposition flushes fresh bounds through persistence channels', async () => { const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8')