mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
fix wayland bugs
This commit is contained in:
+121
-35
@@ -20,6 +20,7 @@ import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
|
||||
import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||
import { normalizeProfile } from '../shared/profileState'
|
||||
import { resolveNativeThemeSource } from '../shared/themeState'
|
||||
import { resolveWindowCapabilities } from '../shared/windowCapabilities'
|
||||
import {
|
||||
clampDraggedMainWindowBounds,
|
||||
raiseWindowAboveNormalPopouts,
|
||||
@@ -89,6 +90,11 @@ const NOW_PLAYING_CONFIG_DEFAULTS = {
|
||||
|
||||
const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180
|
||||
const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64
|
||||
const runtimeWindowCapabilities = resolveWindowCapabilities({
|
||||
platform: process.platform,
|
||||
argv: process.argv,
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
function getProfileLibrary(): FileBackedProfileLibrary {
|
||||
if (!profileLibrary) {
|
||||
@@ -234,7 +240,7 @@ async function syncNativeThemeAppearance(): Promise<void> {
|
||||
}
|
||||
|
||||
function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
|
||||
if (!isMainRendererWindow(window) || !mainRendererReady) return
|
||||
if (!isMainRendererWindow(window) || !mainRendererReady || !supportsGeometryPersistence()) return
|
||||
|
||||
if (mainWindowBoundsTimer) {
|
||||
clearTimeout(mainWindowBoundsTimer)
|
||||
@@ -309,6 +315,14 @@ function getDisplayWorkAreas(): WindowBounds[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function supportsProgrammaticReposition(): boolean {
|
||||
return runtimeWindowCapabilities.supportsProgrammaticReposition
|
||||
}
|
||||
|
||||
function supportsGeometryPersistence(): boolean {
|
||||
return runtimeWindowCapabilities.supportsGeometryPersistence
|
||||
}
|
||||
|
||||
function suppressMainWindowSync(durationMs = MAIN_WINDOW_SYNC_SUPPRESSION_MS): void {
|
||||
suppressMainWindowSyncUntil = Math.max(suppressMainWindowSyncUntil, Date.now() + durationMs)
|
||||
}
|
||||
@@ -349,9 +363,16 @@ function syncMainWindowLogicalBounds(window: BrowserWindow, bounds = window.getB
|
||||
return
|
||||
}
|
||||
|
||||
const x = supportsGeometryPersistence()
|
||||
? bounds.x
|
||||
: mainWindowLogicalBounds?.x ?? 0
|
||||
const y = supportsGeometryPersistence()
|
||||
? bounds.y
|
||||
: mainWindowLogicalBounds?.y ?? 0
|
||||
|
||||
mainWindowLogicalBounds = normalizeMainWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
x,
|
||||
y,
|
||||
width: bounds.width,
|
||||
height: Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - getSettingsHeight(window)),
|
||||
})
|
||||
@@ -361,7 +382,12 @@ function applyMainWindowLogicalBounds(window: BrowserWindow, bounds: WindowBound
|
||||
const logicalBounds = normalizeMainWindowBounds(bounds)
|
||||
mainWindowLogicalBounds = logicalBounds
|
||||
suppressMainWindowSync()
|
||||
window.setBounds(resolveExpandedMainWindowBounds(logicalBounds, getSettingsHeight(window), getDisplayWorkAreas()))
|
||||
const expandedBounds = resolveExpandedMainWindowBounds(logicalBounds, getSettingsHeight(window), getDisplayWorkAreas())
|
||||
if (!supportsGeometryPersistence()) {
|
||||
window.setSize(expandedBounds.width, expandedBounds.height)
|
||||
return
|
||||
}
|
||||
window.setBounds(expandedBounds)
|
||||
}
|
||||
|
||||
function applyLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void {
|
||||
@@ -370,13 +396,24 @@ function applyLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void {
|
||||
return
|
||||
}
|
||||
|
||||
const nextHeight = bounds.height + getSettingsHeight(window)
|
||||
if (!supportsGeometryPersistence()) {
|
||||
window.setSize(bounds.width, nextHeight)
|
||||
return
|
||||
}
|
||||
|
||||
window.setBounds({
|
||||
...bounds,
|
||||
height: bounds.height + getSettingsHeight(window),
|
||||
height: nextHeight,
|
||||
})
|
||||
}
|
||||
|
||||
function setWindowHeight(window: BrowserWindow, bounds: WindowBounds, height: number, y = bounds.y): void {
|
||||
if (!supportsGeometryPersistence()) {
|
||||
window.setSize(bounds.width, height)
|
||||
return
|
||||
}
|
||||
|
||||
window.setBounds({
|
||||
x: bounds.x,
|
||||
y,
|
||||
@@ -452,6 +489,10 @@ function applySettingsHeight(window: BrowserWindow, rawNextHeight: number): void
|
||||
}
|
||||
|
||||
function raiseMainWindowAboveNormalPopouts(): void {
|
||||
if (!supportsProgrammaticReposition()) {
|
||||
return
|
||||
}
|
||||
|
||||
raiseWindowAboveNormalPopouts(mainWindow, scopePopoutWindows.values())
|
||||
}
|
||||
|
||||
@@ -792,7 +833,13 @@ function createMainWindow(): void {
|
||||
}
|
||||
|
||||
function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed() || !mainRendererReady) return
|
||||
if (
|
||||
!mainWindow
|
||||
|| mainWindow.isDestroyed()
|
||||
|| window.isDestroyed()
|
||||
|| !mainRendererReady
|
||||
|| !supportsGeometryPersistence()
|
||||
) return
|
||||
|
||||
const existingTimer = popoutBoundsTimers.get(kind)
|
||||
if (existingTimer) {
|
||||
@@ -844,19 +891,27 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
||||
return existing
|
||||
}
|
||||
|
||||
const mainBounds = mainWindow.getBounds()
|
||||
const fallbackBounds: WindowBounds = {
|
||||
x: mainBounds.x + 40,
|
||||
y: mainBounds.y + 40,
|
||||
width: POPOUT_DEFAULTS.width,
|
||||
height: POPOUT_DEFAULTS.height,
|
||||
}
|
||||
const bounds = normalizeBounds(rawBounds, fallbackBounds)
|
||||
const shouldRestoreGeometry = supportsGeometryPersistence()
|
||||
const mainBounds = shouldRestoreGeometry ? mainWindow.getBounds() : null
|
||||
const fallbackBounds: WindowBounds = mainBounds
|
||||
? {
|
||||
x: mainBounds.x + 40,
|
||||
y: mainBounds.y + 40,
|
||||
width: POPOUT_DEFAULTS.width,
|
||||
height: POPOUT_DEFAULTS.height,
|
||||
}
|
||||
: {
|
||||
x: Math.round(screen.getPrimaryDisplay().workArea.x + 48),
|
||||
y: Math.round(screen.getPrimaryDisplay().workArea.y + 48),
|
||||
width: POPOUT_DEFAULTS.width,
|
||||
height: POPOUT_DEFAULTS.height,
|
||||
}
|
||||
const bounds = shouldRestoreGeometry
|
||||
? normalizeBounds(rawBounds, fallbackBounds)
|
||||
: fallbackBounds
|
||||
suppressNextPopoutBoundsEvents.add(kind)
|
||||
|
||||
const options: BrowserWindowConstructorOptions = {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
minWidth: POPOUT_DEFAULTS.minWidth,
|
||||
@@ -880,6 +935,11 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
|
||||
},
|
||||
}
|
||||
|
||||
if (shouldRestoreGeometry) {
|
||||
options.x = bounds.x
|
||||
options.y = bounds.y
|
||||
}
|
||||
|
||||
const popoutWindow = new BrowserWindow(options)
|
||||
setSettingsHeightForWindow(popoutWindow, 0)
|
||||
scopePopoutWindows.set(kind, popoutWindow)
|
||||
@@ -934,17 +994,19 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
|
||||
const popoutWindow = createScopePopoutWindow(kind, desired.bounds)
|
||||
if (!popoutWindow || popoutWindow.isDestroyed()) continue
|
||||
|
||||
const currentBounds = popoutWindow.getBounds()
|
||||
const nextBounds = normalizeBounds(desired.bounds, currentBounds)
|
||||
const hasBoundsDelta =
|
||||
currentBounds.x !== nextBounds.x
|
||||
|| currentBounds.y !== nextBounds.y
|
||||
|| currentBounds.width !== nextBounds.width
|
||||
|| currentBounds.height !== nextBounds.height
|
||||
if (supportsGeometryPersistence() && desired.bounds) {
|
||||
const currentBounds = popoutWindow.getBounds()
|
||||
const nextBounds = normalizeBounds(desired.bounds, currentBounds)
|
||||
const hasBoundsDelta =
|
||||
currentBounds.x !== nextBounds.x
|
||||
|| currentBounds.y !== nextBounds.y
|
||||
|| currentBounds.width !== nextBounds.width
|
||||
|| currentBounds.height !== nextBounds.height
|
||||
|
||||
if (hasBoundsDelta) {
|
||||
suppressNextPopoutBoundsEvents.add(kind)
|
||||
applyLogicalBounds(popoutWindow, nextBounds)
|
||||
if (hasBoundsDelta) {
|
||||
suppressNextPopoutBoundsEvents.add(kind)
|
||||
applyLogicalBounds(popoutWindow, nextBounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -973,6 +1035,10 @@ function normalizeNowPlayingConfigBounds(raw: unknown, fallback: WindowBounds):
|
||||
}
|
||||
|
||||
function scheduleNowPlayingConfigBoundsSave(window: BrowserWindow): void {
|
||||
if (!supportsGeometryPersistence()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nowPlayingConfigBoundsTimer) {
|
||||
clearTimeout(nowPlayingConfigBoundsTimer)
|
||||
}
|
||||
@@ -991,7 +1057,8 @@ function createNowPlayingConfigWindow(): BrowserWindow {
|
||||
return nowPlayingConfigWindow
|
||||
}
|
||||
|
||||
const anchorBounds = mainWindow?.getBounds()
|
||||
const shouldRestoreGeometry = supportsGeometryPersistence()
|
||||
const anchorBounds = shouldRestoreGeometry ? mainWindow?.getBounds() ?? null : null
|
||||
const fallbackBounds: WindowBounds = anchorBounds
|
||||
? {
|
||||
x: anchorBounds.x + 40,
|
||||
@@ -1005,14 +1072,14 @@ function createNowPlayingConfigWindow(): BrowserWindow {
|
||||
width: NOW_PLAYING_CONFIG_DEFAULTS.width,
|
||||
height: NOW_PLAYING_CONFIG_DEFAULTS.height,
|
||||
}
|
||||
const bounds = normalizeNowPlayingConfigBounds(
|
||||
getWindowStateStore().getNowPlayingConfigWindowBounds(),
|
||||
fallbackBounds,
|
||||
)
|
||||
const bounds = shouldRestoreGeometry
|
||||
? normalizeNowPlayingConfigBounds(
|
||||
getWindowStateStore().getNowPlayingConfigWindowBounds(),
|
||||
fallbackBounds,
|
||||
)
|
||||
: fallbackBounds
|
||||
|
||||
nowPlayingConfigWindow = new BrowserWindow({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
const options: BrowserWindowConstructorOptions = {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
minWidth: NOW_PLAYING_CONFIG_DEFAULTS.minWidth,
|
||||
@@ -1032,7 +1099,14 @@ function createNowPlayingConfigWindow(): BrowserWindow {
|
||||
nodeIntegration: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldRestoreGeometry) {
|
||||
options.x = bounds.x
|
||||
options.y = bounds.y
|
||||
}
|
||||
|
||||
nowPlayingConfigWindow = new BrowserWindow(options)
|
||||
|
||||
const configWindow = nowPlayingConfigWindow
|
||||
|
||||
@@ -1091,6 +1165,10 @@ function setupIPC(): void {
|
||||
})
|
||||
|
||||
ipcMain.on('window:start-move', (event) => {
|
||||
if (!supportsProgrammaticReposition()) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow) return
|
||||
|
||||
@@ -1415,6 +1493,10 @@ function setupIPC(): void {
|
||||
})
|
||||
|
||||
ipcMain.handle('window:get-bounds', (event) => {
|
||||
if (!supportsGeometryPersistence()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow) return null
|
||||
|
||||
@@ -1422,6 +1504,10 @@ function setupIPC(): void {
|
||||
})
|
||||
|
||||
ipcMain.on('window:reposition', (event, position: 'top' | 'bottom') => {
|
||||
if (!supportsProgrammaticReposition()) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetWindow = getWindowFromSender(event.sender)
|
||||
if (!targetWindow) return
|
||||
|
||||
|
||||
@@ -28,15 +28,23 @@ import type {
|
||||
ThemeLibrarySnapshot,
|
||||
} from '../types/theme'
|
||||
import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||
import type { WindowCapabilities } from '../types/windowCapabilities'
|
||||
import type { ResizeDirection } from '../types/windowResize'
|
||||
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
|
||||
import { resolveWindowCapabilities } from '../shared/windowCapabilities'
|
||||
import { getCaptureBackendSupport } from './captureSupport'
|
||||
|
||||
type NativeAddonModule = VisualizerDSP & NativeCaptureAPI
|
||||
const windowCapabilities: WindowCapabilities = resolveWindowCapabilities({
|
||||
platform: process.platform,
|
||||
argv: process.argv,
|
||||
env: process.env,
|
||||
})
|
||||
|
||||
// Expose Electron API to renderer
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
platform: process.platform,
|
||||
windowCapabilities,
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
startWindowMove: () => ipcRenderer.send('window:start-move'),
|
||||
|
||||
+14
-4
@@ -12,6 +12,7 @@ import { useAudioStore } from './stores/audioStore'
|
||||
import { useNowPlayingStore } from './stores/nowPlayingStore'
|
||||
import { useThemeStore } from './stores/themeStore'
|
||||
import { useUiStore } from './stores/uiStore'
|
||||
import { getRendererWindowCapabilities } from './windowCapabilities'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const [toolbarVisible, setToolbarVisible] = useState(false)
|
||||
@@ -36,6 +37,7 @@ export default function App(): JSX.Element {
|
||||
const toggleSettings = useUiStore((s) => s.toggleSettings)
|
||||
const setSettingsOpen = useUiStore((s) => s.setSettingsOpen)
|
||||
const showBanner = useUiStore((s) => s.showBanner)
|
||||
const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions
|
||||
|
||||
const isNowPlayingVisible = !hiddenScopes.has('nowPlaying')
|
||||
&& (scopeOrder.includes('nowPlaying') || scopePopouts.nowPlaying?.poppedOut === true)
|
||||
@@ -180,15 +182,23 @@ export default function App(): JSX.Element {
|
||||
}, [setSettingsOpen])
|
||||
|
||||
const handleAltDragStart = useCallback((event: React.MouseEvent) => {
|
||||
if (useNativeDragRegions) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.altKey && event.button === 0) {
|
||||
event.preventDefault()
|
||||
window.electronAPI.startWindowMove()
|
||||
}
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleAltDragEnd = useCallback(() => {
|
||||
if (useNativeDragRegions) {
|
||||
return
|
||||
}
|
||||
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -204,8 +214,8 @@ export default function App(): JSX.Element {
|
||||
className="prism-app"
|
||||
onMouseEnter={showToolbar}
|
||||
onMouseLeave={scheduleHide}
|
||||
onMouseDown={handleAltDragStart}
|
||||
onMouseUp={handleAltDragEnd}
|
||||
onMouseDown={useNativeDragRegions ? undefined : handleAltDragStart}
|
||||
onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd}
|
||||
>
|
||||
<div
|
||||
className={`prism-toolbar-layer ${toolbarVisible ? 'is-visible' : ''}`.trim()}
|
||||
|
||||
@@ -14,6 +14,7 @@ import WindowResizeOverlay from './WindowResizeOverlay'
|
||||
import { useNowPlayingStore } from '../stores/nowPlayingStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import { useUiStore } from '../stores/uiStore'
|
||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||
|
||||
function GripIcon(): JSX.Element {
|
||||
return (
|
||||
@@ -228,6 +229,7 @@ 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
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -260,18 +262,22 @@ export default function NowPlayingConfigWindow(): JSX.Element {
|
||||
}, [nowPlayingState.configs.astra.baseUrl, nowPlayingState.configs.astra.hasToken])
|
||||
|
||||
const handleToolbarDragStart = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return
|
||||
if (useNativeDragRegions || isToolbarInteractiveTarget(event.target) || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
window.electronAPI.startWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleToolbarDragEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (useNativeDragRegions) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleSaveAstraConfig = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
@@ -352,11 +358,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"
|
||||
onPointerDown={handleToolbarDragStart}
|
||||
onPointerUp={handleToolbarDragEnd}
|
||||
onPointerCancel={handleToolbarDragEnd}
|
||||
onLostPointerCapture={handleToolbarDragEnd}
|
||||
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}
|
||||
>
|
||||
<div className="now-playing-config__toolbar-copy">
|
||||
<div className="now-playing-config__toolbar-title">Now Playing</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { audioRouter } from '../audio/AudioRouter'
|
||||
import { usePerformanceStore } from '../stores/performanceStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||
import { FrameScheduler } from '../visualizers/frameScheduler'
|
||||
import type {
|
||||
ScopePopoutAudioBatch,
|
||||
@@ -81,6 +82,7 @@ export default function ScopePopoutBridge(): null {
|
||||
const flushScheduler = useMemo(() => new FrameScheduler({ frameTarget }), [])
|
||||
const activePopoutKindsRef = useRef<ScopeKind[]>(activePopoutKinds)
|
||||
const sessionStateRef = useRef(audioRouter.getSessionState())
|
||||
const supportsGeometryPersistence = getRendererWindowCapabilities().supportsGeometryPersistence
|
||||
|
||||
useEffect(() => {
|
||||
flushScheduler.setFrameTarget(frameTarget)
|
||||
@@ -94,13 +96,15 @@ export default function ScopePopoutBridge(): null {
|
||||
const syncState = SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = {
|
||||
shouldBeOpen: scopePopouts[kind]?.poppedOut === true && !hiddenScopes.has(kind),
|
||||
bounds: scopePopouts[kind]?.windowBounds,
|
||||
bounds: supportsGeometryPersistence
|
||||
? scopePopouts[kind]?.windowBounds
|
||||
: undefined,
|
||||
}
|
||||
return acc
|
||||
}, {} as ScopePopoutSyncStateMap)
|
||||
|
||||
window.electronAPI.syncScopePopouts(syncState)
|
||||
}, [hiddenScopes, scopePopouts])
|
||||
}, [hiddenScopes, scopePopouts, supportsGeometryPersistence])
|
||||
|
||||
useEffect(() => {
|
||||
for (const kind of activePopoutKinds) {
|
||||
@@ -128,6 +132,7 @@ export default function ScopePopoutBridge(): null {
|
||||
popInScope(kind)
|
||||
})
|
||||
const unsubscribeBoundsChanged = window.electronAPI.onScopePopoutBoundsChanged((kind, bounds) => {
|
||||
if (!supportsGeometryPersistence) return
|
||||
updatePopoutBounds(kind, bounds)
|
||||
})
|
||||
const unsubscribeSettingsUpdate = window.electronAPI.onScopePopoutSettingsUpdate((kind, partial) => {
|
||||
@@ -157,7 +162,7 @@ export default function ScopePopoutBridge(): null {
|
||||
unsubscribeSettingsUpdate()
|
||||
unsubscribeReady()
|
||||
}
|
||||
}, [popInScope, updatePopoutBounds, updateScopeSettings])
|
||||
}, [popInScope, supportsGeometryPersistence, updatePopoutBounds, updateScopeSettings])
|
||||
|
||||
useEffect(() => {
|
||||
for (const kind of AUDIO_SCOPE_KINDS) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout'
|
||||
import { audioRouter } from '../audio/AudioRouter'
|
||||
import { usePerformanceStore } from '../stores/performanceStore'
|
||||
import { FrameScheduler } from '../visualizers/frameScheduler'
|
||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||
|
||||
export default function Strip(): JSX.Element {
|
||||
const scopeOrder = useSettingsStore((s) => s.scopeOrder)
|
||||
@@ -23,6 +24,7 @@ export default function Strip(): JSX.Element {
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
|
||||
const [handleOffsets, setHandleOffsets] = useState<number[]>([])
|
||||
const supportsGeometryPersistence = getRendererWindowCapabilities().supportsGeometryPersistence
|
||||
const dockedScopes = useMemo(
|
||||
() => scopeOrder.filter((k) => !hiddenScopes.has(k) && !scopePopouts[k]?.poppedOut),
|
||||
[hiddenScopes, scopeOrder, scopePopouts],
|
||||
@@ -189,6 +191,11 @@ export default function Strip(): JSX.Element {
|
||||
}, [dockedScopes, setScopeWidthWeight])
|
||||
|
||||
const handlePopoutScope = useCallback(async (kind: ScopeKind): Promise<void> => {
|
||||
if (!supportsGeometryPersistence) {
|
||||
popOutScope(kind)
|
||||
return
|
||||
}
|
||||
|
||||
const element = scopeRefs.current[kind]
|
||||
const rect = element?.getBoundingClientRect()
|
||||
const windowBounds = await window.electronAPI.getWindowBounds()
|
||||
@@ -204,7 +211,7 @@ export default function Strip(): JSX.Element {
|
||||
}
|
||||
|
||||
popOutScope(kind, nextBounds)
|
||||
}, [popOutScope])
|
||||
}, [popOutScope, supportsGeometryPersistence])
|
||||
|
||||
return (
|
||||
<div ref={stripRef} className="scope-strip">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef, type JSX, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUiStore } from '../stores/uiStore'
|
||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||
|
||||
function SettingsIcon(): JSX.Element {
|
||||
return (
|
||||
@@ -78,6 +79,7 @@ interface ToolbarProps {
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILE_ID = 'profile_default'
|
||||
const WAYLAND_REPOSITION_UNAVAILABLE_MESSAGE = 'Top/bottom repositioning is unavailable on native Wayland.'
|
||||
|
||||
function isToolbarInteractiveTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element
|
||||
@@ -107,6 +109,7 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
const [showReposition, setShowReposition] = useState(false)
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
|
||||
const profileButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const { useNativeDragRegions, supportsProgrammaticReposition } = getRendererWindowCapabilities()
|
||||
|
||||
const showProfileErrorBanner = useCallback((error: unknown, fallback: string, includeOpenFolder = false) => {
|
||||
const actions = includeOpenFolder
|
||||
@@ -139,6 +142,12 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsProgrammaticReposition && showReposition) {
|
||||
setShowReposition(false)
|
||||
}
|
||||
}, [showReposition, supportsProgrammaticReposition])
|
||||
|
||||
const handleSaveNew = useCallback(async () => {
|
||||
setIsProfileMenuOpen(false)
|
||||
const result = await window.electronAPI.showDialog({
|
||||
@@ -306,9 +315,13 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
}, [])
|
||||
|
||||
const handleReposition = useCallback((position: 'top' | 'bottom') => {
|
||||
if (!supportsProgrammaticReposition) {
|
||||
return
|
||||
}
|
||||
|
||||
window.electronAPI.repositionWindow(position)
|
||||
setShowReposition(false)
|
||||
}, [])
|
||||
}, [supportsProgrammaticReposition])
|
||||
|
||||
const handleOpenProfileMenu = useCallback(() => {
|
||||
const buttonRect = profileButtonRef.current?.getBoundingClientRect()
|
||||
@@ -329,50 +342,58 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
}, [activeProfileId, profiles])
|
||||
|
||||
const handleDragStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
|
||||
if (event.button !== 0) return
|
||||
if (useNativeDragRegions || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
window.electronAPI.startWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleDragEnd = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
|
||||
if (useNativeDragRegions) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleToolbarDragStart = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (isToolbarInteractiveTarget(event.target) || event.button !== 0) return
|
||||
if (useNativeDragRegions || isToolbarInteractiveTarget(event.target) || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
window.electronAPI.startWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleToolbarDragEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
|
||||
if (useNativeDragRegions) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const activeProfile = activeProfileId ? profiles[activeProfileId] : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="toolbar"
|
||||
onPointerDown={handleToolbarDragStart}
|
||||
onPointerUp={handleToolbarDragEnd}
|
||||
onPointerCancel={handleToolbarDragEnd}
|
||||
onLostPointerCapture={handleToolbarDragEnd}
|
||||
className={`toolbar ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}
|
||||
onPointerDown={useNativeDragRegions ? undefined : handleToolbarDragStart}
|
||||
onPointerUp={useNativeDragRegions ? undefined : handleToolbarDragEnd}
|
||||
onPointerCancel={useNativeDragRegions ? undefined : handleToolbarDragEnd}
|
||||
onLostPointerCapture={useNativeDragRegions ? undefined : handleToolbarDragEnd}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar__grab"
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
onLostPointerCapture={handleDragEnd}
|
||||
className={`toolbar__grab ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}
|
||||
onPointerDown={useNativeDragRegions ? undefined : handleDragStart}
|
||||
onPointerUp={useNativeDragRegions ? undefined : handleDragEnd}
|
||||
onPointerCancel={useNativeDragRegions ? undefined : handleDragEnd}
|
||||
onLostPointerCapture={useNativeDragRegions ? undefined : handleDragEnd}
|
||||
title="Drag window"
|
||||
aria-label="Drag window"
|
||||
>
|
||||
@@ -405,17 +426,21 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
<div className="toolbar__spacer" />
|
||||
|
||||
<div className="toolbar__actions">
|
||||
<div className="toolbar__reposition-wrap">
|
||||
<div
|
||||
className="toolbar__reposition-wrap"
|
||||
title={!supportsProgrammaticReposition ? WAYLAND_REPOSITION_UNAVAILABLE_MESSAGE : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`toolbar__icon-button ${showReposition ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => setShowReposition((prev) => !prev)}
|
||||
title="Reposition window"
|
||||
aria-label="Reposition window"
|
||||
title={supportsProgrammaticReposition ? 'Reposition window' : WAYLAND_REPOSITION_UNAVAILABLE_MESSAGE}
|
||||
aria-label={supportsProgrammaticReposition ? 'Reposition window' : WAYLAND_REPOSITION_UNAVAILABLE_MESSAGE}
|
||||
disabled={!supportsProgrammaticReposition}
|
||||
>
|
||||
<RepositionIcon />
|
||||
</button>
|
||||
{showReposition && (
|
||||
{showReposition && supportsProgrammaticReposition && (
|
||||
<div className="toolbar__reposition-menu">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Vendored
+2
@@ -31,6 +31,7 @@ import type {
|
||||
ThemeLibrarySnapshot,
|
||||
} from '../types/theme'
|
||||
import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||
import type { WindowCapabilities } from '../types/windowCapabilities'
|
||||
import type { ResizeDirection } from '../types/windowResize'
|
||||
|
||||
declare global {
|
||||
@@ -39,6 +40,7 @@ declare global {
|
||||
nativeCaptureAPI: NativeCaptureAPI | null
|
||||
electronAPI: {
|
||||
platform: string
|
||||
windowCapabilities: WindowCapabilities
|
||||
minimize: () => void
|
||||
close: () => void
|
||||
startWindowMove: () => void
|
||||
|
||||
@@ -8,6 +8,7 @@ import ScopeSettingsSection from '../components/ScopeSettingsSection'
|
||||
import WindowResizeOverlay from '../components/WindowResizeOverlay'
|
||||
import { usePerformanceStore } from '../stores/performanceStore'
|
||||
import { useUiStore } from '../stores/uiStore'
|
||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||
import { ScopePopoutDataSource } from './ScopePopoutDataSource'
|
||||
import { FrameScheduler } from '../visualizers/frameScheduler'
|
||||
|
||||
@@ -73,6 +74,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
|
||||
|
||||
useEffect(() => {
|
||||
void window.electronAPI.isAlwaysOnTop().then(setIsAlwaysOnTop)
|
||||
@@ -129,21 +131,25 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
|
||||
}
|
||||
|
||||
const handleDragStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
|
||||
if (event.button !== 0) return
|
||||
if (useNativeDragRegions || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
window.electronAPI.startWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleDragEnd = useCallback((event: ReactPointerEvent<HTMLButtonElement>): void => {
|
||||
if (useNativeDragRegions) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleAltDragStart = useCallback((event: ReactMouseEvent<HTMLDivElement>): void => {
|
||||
if (!event.altKey || event.button !== 0) return
|
||||
if (useNativeDragRegions || !event.altKey || event.button !== 0) return
|
||||
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest('.scope-popout__drag-handle')) {
|
||||
@@ -152,11 +158,15 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
|
||||
|
||||
event.preventDefault()
|
||||
window.electronAPI.startWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
const handleAltDragEnd = useCallback((): void => {
|
||||
if (useNativeDragRegions) {
|
||||
return
|
||||
}
|
||||
|
||||
window.electronAPI.stopWindowMove()
|
||||
}, [])
|
||||
}, [useNativeDragRegions])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (miniSettingsOpen && !prevMiniSettingsOpenRef.current) {
|
||||
@@ -180,8 +190,8 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
|
||||
return (
|
||||
<div
|
||||
className="scope-popout"
|
||||
onMouseDown={handleAltDragStart}
|
||||
onMouseUp={handleAltDragEnd}
|
||||
onMouseDown={useNativeDragRegions ? undefined : handleAltDragStart}
|
||||
onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd}
|
||||
>
|
||||
<div
|
||||
className="scope-popout__viewport"
|
||||
@@ -193,15 +203,15 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
|
||||
miniSettingsOpen ? 'is-expanded' : '',
|
||||
].join(' ').trim()}
|
||||
>
|
||||
<header className="scope-popout__header">
|
||||
<header className={`scope-popout__header ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}>
|
||||
<div className="scope-popout__drag">
|
||||
<button
|
||||
type="button"
|
||||
className="scope-popout__drag-handle"
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
onLostPointerCapture={handleDragEnd}
|
||||
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}
|
||||
aria-label="Drag window"
|
||||
title="Drag window"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type Profile,
|
||||
type ProfileLibrarySnapshot,
|
||||
} from '../../types/profile'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import {
|
||||
createDefaultProfile,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
normalizeScopePopouts,
|
||||
normalizeWidthWeights,
|
||||
} from '../../shared/profileState'
|
||||
import { getRendererWindowCapabilities } from '../windowCapabilities'
|
||||
import { buildProfileDraft, profilesMatch } from './profileDraft'
|
||||
import { useUiStore } from './uiStore'
|
||||
|
||||
@@ -84,12 +85,65 @@ function canUseBrowserStorage(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
}
|
||||
|
||||
function supportsWindowGeometryPersistence(): boolean {
|
||||
return getRendererWindowCapabilities().supportsGeometryPersistence
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
function stripScopePopoutBounds(scopePopouts: ScopePopoutStateMap): ScopePopoutStateMap {
|
||||
return SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = {
|
||||
...scopePopouts[kind],
|
||||
windowBounds: undefined,
|
||||
}
|
||||
return acc
|
||||
}, {} as ScopePopoutStateMap)
|
||||
}
|
||||
|
||||
function buildPersistedScopePopouts(scopePopouts: ScopePopoutStateMap): ScopePopoutStateMap {
|
||||
const normalizedScopePopouts = normalizeScopePopouts(scopePopouts)
|
||||
return supportsWindowGeometryPersistence()
|
||||
? normalizedScopePopouts
|
||||
: stripScopePopoutBounds(normalizedScopePopouts)
|
||||
}
|
||||
|
||||
function restoreBaselineScopePopoutBounds(
|
||||
scopePopouts: ScopePopoutStateMap,
|
||||
baseline: Profile | null,
|
||||
): ScopePopoutStateMap {
|
||||
if (supportsWindowGeometryPersistence() || !baseline) {
|
||||
return scopePopouts
|
||||
}
|
||||
|
||||
return SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = {
|
||||
...scopePopouts[kind],
|
||||
windowBounds: baseline.scopePopouts[kind]?.windowBounds,
|
||||
}
|
||||
return acc
|
||||
}, {} as ScopePopoutStateMap)
|
||||
}
|
||||
|
||||
function restoreBaselineGeometry(
|
||||
state: Pick<SettingsState, 'savedProfileBaseline'>,
|
||||
workingState: WorkingSettingsState,
|
||||
): WorkingSettingsState {
|
||||
if (supportsWindowGeometryPersistence()) {
|
||||
return workingState
|
||||
}
|
||||
|
||||
return {
|
||||
...workingState,
|
||||
scopePopouts: restoreBaselineScopePopoutBounds(workingState.scopePopouts, state.savedProfileBaseline),
|
||||
windowBounds: state.savedProfileBaseline?.windowBounds,
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromStorage(): Partial<PersistedSettingsState> {
|
||||
if (!canUseBrowserStorage()) {
|
||||
return {}
|
||||
@@ -117,13 +171,18 @@ function saveToStorage(state: WorkingSettingsState): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const scopePopouts = buildPersistedScopePopouts(state.scopePopouts)
|
||||
const windowBounds = supportsWindowGeometryPersistence()
|
||||
? state.windowBounds
|
||||
: undefined
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
scopeOrder: state.scopeOrder,
|
||||
hiddenScopes: Array.from(state.hiddenScopes),
|
||||
widthWeights: state.widthWeights,
|
||||
scopeSettings: state.scopeSettings,
|
||||
scopePopouts: state.scopePopouts,
|
||||
windowBounds: state.windowBounds,
|
||||
scopePopouts,
|
||||
windowBounds,
|
||||
}))
|
||||
} catch {
|
||||
// Ignore localStorage write failures.
|
||||
@@ -192,8 +251,10 @@ function createWorkingStateFromPersistedState(state: Partial<PersistedSettingsSt
|
||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(state.hiddenScopes)),
|
||||
widthWeights: normalizeWidthWeights(state.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(state.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(state.scopePopouts),
|
||||
windowBounds: state.windowBounds,
|
||||
scopePopouts: buildPersistedScopePopouts(normalizeScopePopouts(state.scopePopouts)),
|
||||
windowBounds: supportsWindowGeometryPersistence()
|
||||
? state.windowBounds
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +370,7 @@ function syncMissingBaselinePopoutBounds(
|
||||
}
|
||||
|
||||
function applyLoadedProfileEffects(profile: Profile | null): void {
|
||||
if (!profile) {
|
||||
if (!profile || !supportsWindowGeometryPersistence()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -321,7 +382,7 @@ function applyLoadedProfileEffects(profile: Profile | null): void {
|
||||
function syncCurrentMainWindowBounds(
|
||||
set: (updater: (state: SettingsState) => SettingsState) => void,
|
||||
): void {
|
||||
if (!canUseElectronAPI()) {
|
||||
if (!canUseElectronAPI() || !supportsWindowGeometryPersistence()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -444,7 +505,7 @@ async function restoreSavedProfileBaseline(
|
||||
return
|
||||
}
|
||||
|
||||
if (baseline.windowBounds && canUseElectronAPI()) {
|
||||
if (baseline.windowBounds && canUseElectronAPI() && supportsWindowGeometryPersistence()) {
|
||||
window.electronAPI.setWindowBounds(baseline.windowBounds)
|
||||
}
|
||||
|
||||
@@ -460,7 +521,9 @@ async function restoreSavedProfileBaseline(
|
||||
return nextState
|
||||
})
|
||||
|
||||
syncCurrentMainWindowBounds(set)
|
||||
if (supportsWindowGeometryPersistence()) {
|
||||
syncCurrentMainWindowBounds(set)
|
||||
}
|
||||
}
|
||||
|
||||
const stored = loadFromStorage()
|
||||
@@ -503,20 +566,24 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
const hasStoredWorkingState = hasPersistedWorkingState(storedWorkingState)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: !hasStoredWorkingState })
|
||||
if (hasStoredWorkingState) {
|
||||
set((state) => commitWorkingState(
|
||||
state,
|
||||
createWorkingStateFromPersistedState(storedWorkingState),
|
||||
state.savedProfileBaseline,
|
||||
))
|
||||
set((state) => {
|
||||
const nextWorkingState = restoreBaselineGeometry(
|
||||
state,
|
||||
createWorkingStateFromPersistedState(storedWorkingState),
|
||||
)
|
||||
return commitWorkingState(state, nextWorkingState, state.savedProfileBaseline)
|
||||
})
|
||||
|
||||
// Restore window position: use the working-state bounds (last known position,
|
||||
// possibly dirty) or fall back to the saved profile's bounds if none exist.
|
||||
const state = get()
|
||||
const boundsToApply = state.windowBounds ?? state.savedProfileBaseline?.windowBounds ?? null
|
||||
if (boundsToApply) {
|
||||
window.electronAPI.setWindowBounds(boundsToApply)
|
||||
if (supportsWindowGeometryPersistence()) {
|
||||
// Restore window position: use the working-state bounds (last known position,
|
||||
// possibly dirty) or fall back to the saved profile's bounds if none exist.
|
||||
const state = get()
|
||||
const boundsToApply = state.windowBounds ?? state.savedProfileBaseline?.windowBounds ?? null
|
||||
if (boundsToApply) {
|
||||
window.electronAPI.setWindowBounds(boundsToApply)
|
||||
}
|
||||
syncCurrentMainWindowBounds(set)
|
||||
}
|
||||
syncCurrentMainWindowBounds(set)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -615,6 +682,10 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
|
||||
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => {
|
||||
set((state) => {
|
||||
if (!supportsWindowGeometryPersistence()) {
|
||||
return state
|
||||
}
|
||||
|
||||
const isSyncingGeometry = isWithinGeometrySyncWindow(state)
|
||||
const nextBaseline = syncMissingBaselinePopoutBounds(state, kind, bounds)
|
||||
const nextState = commitWorkingState(state, {
|
||||
@@ -637,6 +708,10 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
|
||||
updateMainWindowBounds: (bounds: WindowBounds) => {
|
||||
set((state) => {
|
||||
if (!supportsWindowGeometryPersistence()) {
|
||||
return state
|
||||
}
|
||||
|
||||
const isSyncingGeometry = isWithinGeometrySyncWindow(state)
|
||||
const nextBaseline = syncMissingBaselineWindowBounds(state, bounds)
|
||||
const nextState = commitWorkingState(state, { windowBounds: bounds }, nextBaseline)
|
||||
|
||||
@@ -298,6 +298,11 @@ select {
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.05);
|
||||
}
|
||||
|
||||
.toolbar.is-native-drag {
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbar__grab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -313,6 +318,10 @@ select {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.toolbar__grab.is-native-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.toolbar__grab:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -387,6 +396,7 @@ select {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.toolbar__profile-button:hover,
|
||||
@@ -420,6 +430,7 @@ select {
|
||||
|
||||
.toolbar__reposition-wrap {
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.toolbar__reposition-menu {
|
||||
@@ -435,6 +446,7 @@ select {
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.toolbar__reposition-option {
|
||||
@@ -449,6 +461,7 @@ select {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 120ms ease, background-color 120ms ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.toolbar__reposition-option:hover {
|
||||
@@ -456,6 +469,11 @@ select {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.toolbar__reposition-option:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.toolbar__chip {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
@@ -490,6 +508,7 @@ select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.toolbar__icon-button {
|
||||
@@ -509,6 +528,7 @@ select {
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.toolbar__icon-button svg {
|
||||
@@ -516,7 +536,7 @@ select {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.toolbar__icon-button:hover {
|
||||
.toolbar__icon-button:hover:not(:disabled) {
|
||||
color: var(--control-text);
|
||||
border-color: var(--control-border-active);
|
||||
background: var(--control-bg-hover);
|
||||
@@ -529,12 +549,18 @@ select {
|
||||
background: var(--control-bg-active);
|
||||
}
|
||||
|
||||
.toolbar__icon-button--danger:hover {
|
||||
.toolbar__icon-button--danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.toolbar__icon-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.46;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.scope-strip {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -1805,6 +1831,11 @@ select {
|
||||
background: var(--toolbar-bg);
|
||||
}
|
||||
|
||||
.scope-popout__header.is-native-drag {
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.scope-popout__drag {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1829,6 +1860,10 @@ select {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.scope-popout__drag-handle.is-native-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.scope-popout__drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -1894,7 +1929,7 @@ select {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.scope-popout__button:hover,
|
||||
.scope-popout__button:hover:not(:disabled),
|
||||
.scope-popout__button.is-active {
|
||||
color: var(--accent);
|
||||
border-color: var(--control-border-active);
|
||||
@@ -1902,6 +1937,12 @@ select {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scope-popout__button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.46;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.toolbar__profile-button:focus-visible,
|
||||
.toolbar__chip:focus-visible,
|
||||
.toolbar__icon-button:focus-visible,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DEFAULT_WINDOW_CAPABILITIES, resolveWindowCapabilities } from '../shared/windowCapabilities'
|
||||
import type { WindowCapabilities } from '../types/windowCapabilities'
|
||||
|
||||
export function getRendererWindowCapabilities(): WindowCapabilities {
|
||||
if (typeof window === 'undefined' || typeof window.electronAPI === 'undefined') {
|
||||
return { ...DEFAULT_WINDOW_CAPABILITIES }
|
||||
}
|
||||
|
||||
if (window.electronAPI.windowCapabilities) {
|
||||
return window.electronAPI.windowCapabilities
|
||||
}
|
||||
|
||||
return resolveWindowCapabilities({ platform: window.electronAPI.platform })
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { WindowCapabilities, WindowDisplayServer } from '../types/windowCapabilities'
|
||||
|
||||
interface WindowCapabilityResolutionOptions {
|
||||
platform: string
|
||||
argv?: readonly string[]
|
||||
env?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export const DEFAULT_WINDOW_CAPABILITIES: WindowCapabilities = {
|
||||
displayServer: 'other',
|
||||
useNativeDragRegions: false,
|
||||
supportsProgrammaticReposition: true,
|
||||
supportsGeometryPersistence: true,
|
||||
}
|
||||
|
||||
function readSwitchValue(argv: readonly string[], switchName: string): string | null {
|
||||
const exactPrefix = `--${switchName}=`
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index]
|
||||
if (value.startsWith(exactPrefix)) {
|
||||
return value.slice(exactPrefix.length)
|
||||
}
|
||||
|
||||
if (value === `--${switchName}`) {
|
||||
const nextValue = argv[index + 1]
|
||||
if (typeof nextValue === 'string' && !nextValue.startsWith('--')) {
|
||||
return nextValue
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveLinuxDisplayServer(
|
||||
argv: readonly string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): WindowDisplayServer {
|
||||
const ozonePlatform = readSwitchValue(argv, 'ozone-platform')?.toLowerCase() ?? null
|
||||
|
||||
if (ozonePlatform === 'x11') {
|
||||
return 'x11'
|
||||
}
|
||||
|
||||
if (ozonePlatform === 'wayland') {
|
||||
return 'wayland'
|
||||
}
|
||||
|
||||
const sessionType = env.XDG_SESSION_TYPE?.toLowerCase() ?? ''
|
||||
const hasWaylandDisplay = Boolean(env.WAYLAND_DISPLAY)
|
||||
const hasX11Display = Boolean(env.DISPLAY)
|
||||
|
||||
if (sessionType === 'wayland') {
|
||||
return 'wayland'
|
||||
}
|
||||
|
||||
if (sessionType === 'x11') {
|
||||
return 'x11'
|
||||
}
|
||||
|
||||
if (hasWaylandDisplay && !hasX11Display) {
|
||||
return 'wayland'
|
||||
}
|
||||
|
||||
if (hasX11Display) {
|
||||
return 'x11'
|
||||
}
|
||||
|
||||
if (hasWaylandDisplay) {
|
||||
return 'wayland'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function resolveWindowCapabilities(options: WindowCapabilityResolutionOptions): WindowCapabilities {
|
||||
if (options.platform !== 'linux') {
|
||||
return { ...DEFAULT_WINDOW_CAPABILITIES }
|
||||
}
|
||||
|
||||
const displayServer = resolveLinuxDisplayServer(options.argv ?? [], options.env ?? {})
|
||||
const isNativeWayland = displayServer === 'wayland'
|
||||
|
||||
return {
|
||||
displayServer,
|
||||
useNativeDragRegions: isNativeWayland,
|
||||
supportsProgrammaticReposition: !isNativeWayland,
|
||||
supportsGeometryPersistence: !isNativeWayland,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type WindowDisplayServer = 'other' | 'x11' | 'wayland' | 'unknown'
|
||||
|
||||
export interface WindowCapabilities {
|
||||
displayServer: WindowDisplayServer
|
||||
useNativeDragRegions: boolean
|
||||
supportsProgrammaticReposition: boolean
|
||||
supportsGeometryPersistence: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user