diff --git a/src/main/index.ts b/src/main/index.ts index bb88b3e..3d4a083 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -25,6 +25,11 @@ 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 { + clampDraggedMainWindowBounds, + raiseWindowAboveNormalPopouts, + resolveExpandedMainWindowBounds, +} from '../shared/windowGeometry' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' import { AstraIntegrationService } from './services/astraIntegration' @@ -34,7 +39,7 @@ import { FileBackedWindowStateStore } from './windowStateStore' let mainWindow: BrowserWindow | null = null let moveInterval: ReturnType | null = null let moveStartCursor: { x: number; y: number } | null = null -let moveStartPosition: number[] | null = null +let moveStartBounds: WindowBounds | null = null let resizeInterval: ReturnType | null = null let resizeWindow: BrowserWindow | null = null let resizeStartCursor: { x: number; y: number } | null = null @@ -44,7 +49,8 @@ let mainWindowBoundsTimer: ReturnType | null = null let mainRendererReady = false let allowMainWindowClose = false let mainWindowClosePending = false -let suppressNextMainWindowBoundsEvent = false +let suppressMainWindowSyncUntil = 0 +let mainWindowLogicalBounds: WindowBounds | null = null const scopePopoutWindows = new Map() const scopePopoutCloseAllowed = new Set() @@ -74,6 +80,9 @@ const POPOUT_DEFAULTS = { minHeight: 160, } +const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180 +const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64 + function getProfileLibrary(): FileBackedProfileLibrary { if (!profileLibrary) { profileLibrary = new FileBackedProfileLibrary( @@ -176,6 +185,7 @@ function focusMainWindow(): void { } mainWindow.show() mainWindow.focus() + raiseMainWindowAboveNormalPopouts() } function getErrorMessage(error: unknown, fallback: string): string { @@ -244,8 +254,7 @@ function scheduleMainWindowBoundsSave(window: BrowserWindow): void { mainWindowBoundsTimer = setTimeout(() => { mainWindowBoundsTimer = null if (window.isDestroyed() || window.webContents.isDestroyed()) return - if (suppressNextMainWindowBoundsEvent) { - suppressNextMainWindowBoundsEvent = false + if (isMainWindowSyncSuppressed()) { return } window.webContents.send('window:bounds-changed', toLogicalBounds(window)) @@ -293,6 +302,32 @@ function getBaseMinHeight(window: BrowserWindow): number { return isMainRendererWindow(window) ? WINDOW_DEFAULTS.minHeight : POPOUT_DEFAULTS.minHeight } +function normalizeMainWindowBounds(bounds: WindowBounds): WindowBounds { + return { + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(WINDOW_DEFAULTS.minWidth, Math.round(bounds.width)), + height: Math.max(WINDOW_DEFAULTS.minHeight, Math.round(bounds.height)), + } +} + +function getDisplayWorkAreas(): WindowBounds[] { + return screen.getAllDisplays().map((display) => ({ + x: display.workArea.x, + y: display.workArea.y, + width: display.workArea.width, + height: display.workArea.height, + })) +} + +function suppressMainWindowSync(durationMs = MAIN_WINDOW_SYNC_SUPPRESSION_MS): void { + suppressMainWindowSyncUntil = Math.max(suppressMainWindowSyncUntil, Date.now() + durationMs) +} + +function isMainWindowSyncSuppressed(): boolean { + return suppressMainWindowSyncUntil > Date.now() +} + function getSettingsHeight(window: BrowserWindow | null): number { if (!window) return 0 return windowSettingsHeights.get(window.id) ?? 0 @@ -310,13 +345,42 @@ function setSettingsHeightForWindow(window: BrowserWindow, height: number): void } function toLogicalBounds(window: BrowserWindow, bounds = window.getBounds()): WindowBounds { + if (isMainRendererWindow(window) && mainWindowLogicalBounds) { + return { ...mainWindowLogicalBounds } + } + return { ...bounds, height: Math.max(getBaseMinHeight(window), bounds.height - getSettingsHeight(window)), } } +function syncMainWindowLogicalBounds(window: BrowserWindow, bounds = window.getBounds()): void { + if (!isMainRendererWindow(window)) { + return + } + + mainWindowLogicalBounds = normalizeMainWindowBounds({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - getSettingsHeight(window)), + }) +} + +function applyMainWindowLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void { + const logicalBounds = normalizeMainWindowBounds(bounds) + mainWindowLogicalBounds = logicalBounds + suppressMainWindowSync() + window.setBounds(resolveExpandedMainWindowBounds(logicalBounds, getSettingsHeight(window), getDisplayWorkAreas())) +} + function applyLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void { + if (isMainRendererWindow(window)) { + applyMainWindowLogicalBounds(window, bounds) + return + } + window.setBounds({ ...bounds, height: bounds.height + getSettingsHeight(window), @@ -335,12 +399,28 @@ function setWindowHeight(window: BrowserWindow, bounds: WindowBounds, height: nu function applySettingsHeight(window: BrowserWindow, rawNextHeight: number): void { const currentSettingsHeight = getSettingsHeight(window) const nextSettingsHeight = Math.max(0, Math.round(rawNextHeight)) - const delta = nextSettingsHeight - currentSettingsHeight const baseMinHeight = getBaseMinHeight(window) const [minW] = window.getMinimumSize() window.setMinimumSize(minW, baseMinHeight + nextSettingsHeight) + if (currentSettingsHeight === nextSettingsHeight) { + return + } + + if (isMainRendererWindow(window)) { + if (!mainWindowLogicalBounds) { + syncMainWindowLogicalBounds(window) + } + setSettingsHeightForWindow(window, nextSettingsHeight) + applyMainWindowLogicalBounds(window, toLogicalBounds(window)) + if (currentSettingsHeight === 0 && nextSettingsHeight > 0) { + raiseMainWindowAboveNormalPopouts() + } + return + } + + const delta = nextSettingsHeight - currentSettingsHeight if (delta !== 0) { const bounds = window.getBounds() const newHeight = Math.max(baseMinHeight + nextSettingsHeight, bounds.height + delta) @@ -382,6 +462,10 @@ function applySettingsHeight(window: BrowserWindow, rawNextHeight: number): void setSettingsHeightForWindow(window, nextSettingsHeight) } +function raiseMainWindowAboveNormalPopouts(): void { + raiseWindowAboveNormalPopouts(mainWindow, scopePopoutWindows.values()) +} + function sendToRenderer(sender: WebContents, channel: string, ...args: unknown[]): void { if (!sender.isDestroyed()) { sender.send(channel, ...args) @@ -398,7 +482,7 @@ function stopWindowMoveController(): void { moveInterval = null } moveStartCursor = null - moveStartPosition = null + moveStartBounds = null } function stopWindowResizeController(): void { @@ -651,6 +735,7 @@ function createMainWindow(): void { backgroundThrottling: false, }, }) + syncMainWindowLogicalBounds(mainWindow) mainWindow.on('close', (event) => { if (allowMainWindowClose || !mainRendererReady || mainWindow?.webContents.isDestroyed()) { @@ -681,6 +766,8 @@ function createMainWindow(): void { windowSettingsHeights.delete(mainWindow.id) windowSettingsBottomAnchors.delete(mainWindow.id) } + mainWindowLogicalBounds = null + suppressMainWindowSyncUntil = 0 mainRendererReady = false allowMainWindowClose = false mainWindowClosePending = false @@ -693,12 +780,21 @@ function createMainWindow(): void { mainWindow.on('move', () => { if (!mainWindow) return + if (!isMainWindowSyncSuppressed()) { + syncMainWindowLogicalBounds(mainWindow) + } scheduleMainWindowBoundsSave(mainWindow) }) mainWindow.on('resize', () => { if (!mainWindow) return + if (!isMainWindowSyncSuppressed()) { + syncMainWindowLogicalBounds(mainWindow) + } scheduleMainWindowBoundsSave(mainWindow) }) + mainWindow.on('focus', () => { + raiseMainWindowAboveNormalPopouts() + }) loadRendererTarget(mainWindow, { window: 'main' }) } @@ -799,6 +895,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro popoutWindow.once('ready-to-show', () => { if (!popoutWindow.isDestroyed()) { popoutWindow.show() + raiseMainWindowAboveNormalPopouts() } }) @@ -924,14 +1021,26 @@ function setupIPC(): void { stopWindowMoveController() const cursor = screen.getCursorScreenPoint() moveStartCursor = { x: cursor.x, y: cursor.y } - moveStartPosition = targetWindow.getPosition() + moveStartBounds = targetWindow.getBounds() moveInterval = setInterval(() => { - if (!targetWindow || targetWindow.isDestroyed() || !moveStartCursor || !moveStartPosition) return + if (!targetWindow || targetWindow.isDestroyed() || !moveStartCursor || !moveStartBounds) return const current = screen.getCursorScreenPoint() const dx = current.x - moveStartCursor.x const dy = current.y - moveStartCursor.y - targetWindow.setPosition(moveStartPosition[0] + dx, moveStartPosition[1] + dy) + + if (isMainRendererWindow(targetWindow)) { + const nextBounds = clampDraggedMainWindowBounds({ + x: moveStartBounds.x + dx, + y: moveStartBounds.y + dy, + width: moveStartBounds.width, + height: moveStartBounds.height, + }, getDisplayWorkAreas(), MAIN_WINDOW_VISIBLE_GRAB_MARGIN) + targetWindow.setPosition(nextBounds.x, nextBounds.y) + return + } + + targetWindow.setPosition(moveStartBounds.x + dx, moveStartBounds.y + dy) }, 16) }) @@ -1212,9 +1321,6 @@ function setupIPC(): void { const targetWindow = getWindowFromSender(event.sender) if (!targetWindow) return - if (isMainRendererWindow(targetWindow)) { - suppressNextMainWindowBoundsEvent = true - } applyLogicalBounds(targetWindow, bounds) }) @@ -1231,6 +1337,20 @@ function setupIPC(): void { const display = screen.getDisplayMatching(targetWindow.getBounds()) const workArea = display.workArea + + if (isMainRendererWindow(targetWindow)) { + const logicalBounds = toLogicalBounds(targetWindow) + applyLogicalBounds(targetWindow, { + x: workArea.x, + y: position === 'top' + ? workArea.y + : workArea.y + workArea.height - logicalBounds.height, + width: workArea.width, + height: logicalBounds.height, + }) + return + } + const [, height] = targetWindow.getSize() if (position === 'top') { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8dd1a5e..a93764b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,14 +6,13 @@ 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' import { useAstraStore } from './stores/astraStore' import { useAudioStore } from './stores/audioStore' import { useThemeStore } from './stores/themeStore' import { useUiStore } from './stores/uiStore' -const DEFAULT_SETTINGS_HEIGHT = 400 - export default function App(): JSX.Element { const [toolbarVisible, setToolbarVisible] = useState(false) const [settingsPanelHeight, setSettingsPanelHeight] = useState(0) @@ -136,11 +135,12 @@ export default function App(): JSX.Element { updateMainWindowBounds, ]) - const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0 - ? settingsPanelHeight + bottomBarHeight - : DEFAULT_SETTINGS_HEIGHT - - const settingsHeight = settingsOpen ? measuredSettingsHeight : 0 + const settingsHeight = resolveMainWindowSettingsHeight( + settingsOpen, + settingsPanelHeight, + bottomBarHeight, + ) + const settingsVisible = settingsOpen && settingsHeight > 0 useLayoutEffect(() => { window.electronAPI.setSettingsHeight(settingsHeight) @@ -211,12 +211,14 @@ export default function App(): JSX.Element { - {settingsOpen && ( -
- - -
- )} +
+ + +
diff --git a/src/renderer/mainWindowSettings.ts b/src/renderer/mainWindowSettings.ts new file mode 100644 index 0000000..f9572c9 --- /dev/null +++ b/src/renderer/mainWindowSettings.ts @@ -0,0 +1,15 @@ +export function resolveMainWindowSettingsHeight( + settingsOpen: boolean, + settingsPanelHeight: number, + bottomBarHeight: number, +): number { + if (!settingsOpen) { + return 0 + } + + if (settingsPanelHeight <= 0 || bottomBarHeight <= 0) { + return 0 + } + + return Math.ceil(settingsPanelHeight) + Math.ceil(bottomBarHeight) +} diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 9fb4411..a13a2f2 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -207,6 +207,13 @@ select { box-shadow: 0 -16px 34px rgba(0, 0, 0, 0.34); } +.prism-settings-region.is-hidden { + visibility: hidden; + pointer-events: none; + border-top-color: transparent; + box-shadow: none; +} + .app-banner-layer { position: absolute; top: 10px; diff --git a/src/shared/windowGeometry.ts b/src/shared/windowGeometry.ts new file mode 100644 index 0000000..b048286 --- /dev/null +++ b/src/shared/windowGeometry.ts @@ -0,0 +1,164 @@ +import type { WindowBounds } from '../types/popout' + +export interface WorkAreaBounds { + x: number + y: number + width: number + height: number +} + +export interface StackableWindowLike { + isDestroyed(): boolean + isAlwaysOnTop(): boolean + moveTop(): void +} + +function getRight(bounds: WorkAreaBounds): number { + return bounds.x + bounds.width +} + +function getBottom(bounds: WorkAreaBounds): number { + return bounds.y + bounds.height +} + +function rangesOverlap(startA: number, endA: number, startB: number, endB: number): boolean { + return startA < endB && startB < endA +} + +function rectsIntersect(a: WorkAreaBounds, b: WorkAreaBounds): boolean { + return rangesOverlap(a.x, getRight(a), b.x, getRight(b)) + && rangesOverlap(a.y, getBottom(a), b.y, getBottom(b)) +} + +function clamp(value: number, min: number, max: number): number { + if (min > max) { + return Math.round((min + max) / 2) + } + + return Math.min(max, Math.max(min, value)) +} + +function unionBounds(boundsList: readonly WorkAreaBounds[]): WorkAreaBounds { + const left = Math.min(...boundsList.map((bounds) => bounds.x)) + const top = Math.min(...boundsList.map((bounds) => bounds.y)) + const right = Math.max(...boundsList.map((bounds) => getRight(bounds))) + const bottom = Math.max(...boundsList.map((bounds) => getBottom(bounds))) + + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + } +} + +function normalizeWorkAreas(workAreas: readonly WorkAreaBounds[]): WorkAreaBounds[] { + return workAreas + .filter((bounds) => bounds.width > 0 && bounds.height > 0) + .map((bounds) => ({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + })) +} + +export function buildDisplayEnvelope( + anchorBounds: WindowBounds, + projectedBounds: WorkAreaBounds, + workAreas: readonly WorkAreaBounds[], +): WorkAreaBounds { + const normalizedWorkAreas = normalizeWorkAreas(workAreas) + if (normalizedWorkAreas.length === 0) { + return { ...projectedBounds } + } + + const relevant = normalizedWorkAreas.filter((workArea) => { + return rectsIntersect(workArea, projectedBounds) + || rangesOverlap(workArea.x, getRight(workArea), anchorBounds.x, getRight(anchorBounds)) + }) + + return unionBounds(relevant.length > 0 ? relevant : normalizedWorkAreas) +} + +export function clampBoundsWithinEnvelope( + bounds: WindowBounds, + envelope: WorkAreaBounds, +): WindowBounds { + const maxX = getRight(envelope) - bounds.width + const maxY = getBottom(envelope) - bounds.height + + return { + x: clamp(bounds.x, envelope.x, maxX), + y: clamp(bounds.y, envelope.y, maxY), + width: bounds.width, + height: bounds.height, + } +} + +export function clampBoundsWithVisibleMargin( + bounds: WindowBounds, + envelope: WorkAreaBounds, + visibleMargin: number, +): WindowBounds { + const margin = Math.max(0, Math.round(visibleMargin)) + const minX = envelope.x + margin - bounds.width + const maxX = getRight(envelope) - margin + const minY = envelope.y + margin - bounds.height + const maxY = getBottom(envelope) - margin + + return { + x: clamp(bounds.x, minX, maxX), + y: clamp(bounds.y, minY, maxY), + width: bounds.width, + height: bounds.height, + } +} + +export function resolveExpandedMainWindowBounds( + logicalBounds: WindowBounds, + settingsHeight: number, + workAreas: readonly WorkAreaBounds[], +): WindowBounds { + const nextSettingsHeight = Math.max(0, Math.round(settingsHeight)) + if (nextSettingsHeight === 0) { + return { ...logicalBounds } + } + + const projectedBounds: WindowBounds = { + x: logicalBounds.x, + y: logicalBounds.y, + width: logicalBounds.width, + height: logicalBounds.height + nextSettingsHeight, + } + + const envelope = buildDisplayEnvelope(logicalBounds, projectedBounds, workAreas) + return clampBoundsWithinEnvelope(projectedBounds, envelope) +} + +export function clampDraggedMainWindowBounds( + actualBounds: WindowBounds, + workAreas: readonly WorkAreaBounds[], + visibleMargin: number, +): WindowBounds { + const envelope = buildDisplayEnvelope(actualBounds, actualBounds, workAreas) + return clampBoundsWithVisibleMargin(actualBounds, envelope, visibleMargin) +} + +export function raiseWindowAboveNormalPopouts( + mainWindow: StackableWindowLike | null, + popouts: Iterable, +): boolean { + if (!mainWindow || mainWindow.isDestroyed()) { + return false + } + + for (const popout of popouts) { + if (!popout.isDestroyed() && !popout.isAlwaysOnTop()) { + mainWindow.moveTop() + return true + } + } + + return false +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 371c635..e952351 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -10,6 +10,7 @@ import { getHorizontalWheelScrollResult, normalizeWheelDelta, } from '../src/renderer/utils/horizontalWheelScroll' +import { resolveMainWindowSettingsHeight } from '../src/renderer/mainWindowSettings' import { formatAstraTime, getAstraPlaybackProgress, @@ -17,6 +18,11 @@ import { import { createDefaultProfile, } from '../src/shared/profileState' +import { + clampDraggedMainWindowBounds, + raiseWindowAboveNormalPopouts, + resolveExpandedMainWindowBounds, +} from '../src/shared/windowGeometry' import { calculateResizedWindowBounds } from '../src/shared/windowResize' import { createDefaultTheme, resolveNativeThemeSource, resolveTheme } from '../src/shared/themeState' import { usePerformanceStore } from '../src/renderer/stores/performanceStore' @@ -418,6 +424,28 @@ function createFakeTransportBridge(): { } } +function createFakeStackableWindow(alwaysOnTop = false): { + getMoveTopCalls: () => number + window: { + isDestroyed: () => boolean + isAlwaysOnTop: () => boolean + moveTop: () => void + } +} { + let moveTopCalls = 0 + + return { + getMoveTopCalls: () => moveTopCalls, + window: { + isDestroyed: () => false, + isAlwaysOnTop: () => alwaysOnTop, + moveTop: () => { + moveTopCalls += 1 + }, + }, + } +} + function readSpectrumMagnitudes(transport: NativeVisualizerTransport, size = 8): number[] { const output = new Float32Array(size) const count = transport.fillLatestSpectrumMagnitudes(output) @@ -1141,6 +1169,78 @@ test('toggleScope appends astra to the scope order when it is enabled from an op } }) +test('resolveMainWindowSettingsHeight waits for real measurements instead of using a placeholder height', () => { + assert.equal(resolveMainWindowSettingsHeight(false, 312, 96), 0) + assert.equal(resolveMainWindowSettingsHeight(true, 0, 96), 0) + assert.equal(resolveMainWindowSettingsHeight(true, 312, 0), 0) + assert.equal(resolveMainWindowSettingsHeight(true, 312, 96), 408) +}) + +test('expanded main-window bounds can push upward into an overlapping display above', () => { + const resolved = resolveExpandedMainWindowBounds( + { x: 700, y: 1110, width: 900, height: 180 }, + 400, + [ + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 600, y: 1080, width: 720, height: 260 }, + ], + ) + + assert.equal(resolved.x, 700) + assert.equal(resolved.y, 760) + assert.equal(resolved.height, 580) +}) + +test('expanded main-window bounds can span into a taller side display instead of clamping to one display', () => { + const resolved = resolveExpandedMainWindowBounds( + { x: 650, y: 650, width: 400, height: 180 }, + 300, + [ + { x: 0, y: 0, width: 800, height: 800 }, + { x: 800, y: 0, width: 800, height: 1200 }, + ], + ) + + assert.equal(resolved.x, 650) + assert.equal(resolved.y, 650) + assert.equal(resolved.height, 480) +}) + +test('dragged main-window bounds keep a visible grab margin without sticking at a display seam', () => { + const clamped = clampDraggedMainWindowBounds( + { x: 760, y: 120, width: 400, height: 220 }, + [ + { x: 0, y: 0, width: 800, height: 900 }, + { x: 800, y: 0, width: 800, height: 900 }, + ], + 64, + ) + + assert.equal(clamped.x, 760) + assert.equal(clamped.y, 120) +}) + +test('raiseWindowAboveNormalPopouts raises the main window when an unpinned popout exists', () => { + const main = createFakeStackableWindow() + const normalPopout = createFakeStackableWindow(false) + const pinnedPopout = createFakeStackableWindow(true) + + const raised = raiseWindowAboveNormalPopouts(main.window, [normalPopout.window, pinnedPopout.window]) + + assert.equal(raised, true) + assert.equal(main.getMoveTopCalls(), 1) +}) + +test('raiseWindowAboveNormalPopouts leaves pinned popouts above the main window', () => { + const main = createFakeStackableWindow() + const pinnedPopout = createFakeStackableWindow(true) + + const raised = raiseWindowAboveNormalPopouts(main.window, [pinnedPopout.window]) + + assert.equal(raised, false) + assert.equal(main.getMoveTopCalls(), 0) +}) + test('main-window bounds updates persist working state in Electron mode', () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage()