native os window snapping

This commit is contained in:
Boof2015
2026-05-19 13:44:35 -04:00
parent e4c82c0a64
commit eccd7227d9
6 changed files with 58 additions and 56 deletions
+11 -15
View File
@@ -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<DialogResult> {
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,
-2
View File
@@ -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 {
<BottomBar onClose={handleCloseSettings} onHeightChange={setBottomBarHeight} />
</div>
<WindowResizeOverlay />
</div>
)
}
@@ -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<NowPlayingProviderId | null>('astra')
const [draggedProviderId, setDraggedProviderId] = useState<NowPlayingProviderId | null>(null)
const [dropTargetProviderId, setDropTargetProviderId] = useState<NowPlayingProviderId | null>(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<HTMLDivElement>): 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<HTMLDivElement>): 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<void> => {
try {
@@ -437,11 +436,11 @@ export default function NowPlayingConfigWindow(): JSX.Element {
<div className="now-playing-config">
<div className="now-playing-config__shell">
<header
className={`toolbar now-playing-config__toolbar ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}
onPointerDown={useNativeDragRegions ? undefined : handleToolbarDragStart}
onPointerUp={useNativeDragRegions ? undefined : handleToolbarDragEnd}
onPointerCancel={useNativeDragRegions ? undefined : handleToolbarDragEnd}
onLostPointerCapture={useNativeDragRegions ? undefined : handleToolbarDragEnd}
className={`toolbar now-playing-config__toolbar ${useWindowManagerDragRegions ? 'is-native-drag' : ''}`.trim()}
onPointerDown={useWindowManagerDragRegions ? undefined : handleToolbarDragStart}
onPointerUp={useWindowManagerDragRegions ? undefined : handleToolbarDragEnd}
onPointerCancel={useWindowManagerDragRegions ? undefined : handleToolbarDragEnd}
onLostPointerCapture={useWindowManagerDragRegions ? undefined : handleToolbarDragEnd}
>
<div className="now-playing-config__toolbar-copy">
<div className="now-playing-config__toolbar-title">Now Playing</div>
@@ -699,7 +698,6 @@ export default function NowPlayingConfigWindow(): JSX.Element {
</div>
<AppBanner />
<WindowResizeOverlay />
</div>
)
}
+17 -20
View File
@@ -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<HTMLButtonElement>): 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<HTMLButtonElement>): 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<HTMLDivElement>): 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}
>
<div
className="scope-popout__viewport"
@@ -224,15 +223,15 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
cursorInsideWindow ? 'is-cursor-inside' : '',
].join(' ').trim()}
>
<header className={`scope-popout__header ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}>
<header className={`scope-popout__header ${useWindowManagerDragRegions ? 'is-native-drag' : ''}`.trim()}>
<div className="scope-popout__drag">
<button
type="button"
className={`scope-popout__drag-handle ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}
onPointerDown={useNativeDragRegions ? undefined : handleDragStart}
onPointerUp={useNativeDragRegions ? undefined : handleDragEnd}
onPointerCancel={useNativeDragRegions ? undefined : handleDragEnd}
onLostPointerCapture={useNativeDragRegions ? undefined : handleDragEnd}
className={`scope-popout__drag-handle ${useWindowManagerDragRegions ? 'is-native-drag' : ''}`.trim()}
onPointerDown={useWindowManagerDragRegions ? undefined : handleDragStart}
onPointerUp={useWindowManagerDragRegions ? undefined : handleDragEnd}
onPointerCancel={useWindowManagerDragRegions ? undefined : handleDragEnd}
onLostPointerCapture={useWindowManagerDragRegions ? undefined : handleDragEnd}
aria-label="Drag window"
title="Drag window"
>
@@ -305,8 +304,6 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
</div>
</div>
)}
<WindowResizeOverlay />
</div>
)
}
+1 -1
View File
@@ -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,
+19 -6
View File
@@ -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')