improve settings popout on main window

This commit is contained in:
Boof2015
2026-03-31 16:43:29 -04:00
parent afc26737cf
commit d8700173f5
6 changed files with 433 additions and 25 deletions
+132 -12
View File
@@ -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<typeof setInterval> | null = null
let moveStartCursor: { x: number; y: number } | null = null
let moveStartPosition: number[] | null = null
let moveStartBounds: WindowBounds | null = null
let resizeInterval: ReturnType<typeof setInterval> | null = null
let resizeWindow: BrowserWindow | null = null
let resizeStartCursor: { x: number; y: number } | null = null
@@ -44,7 +49,8 @@ let mainWindowBoundsTimer: ReturnType<typeof setTimeout> | 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<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
@@ -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') {
+15 -13
View File
@@ -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 {
<AppBanner />
{settingsOpen && (
<div className="prism-settings-region" style={{ height: settingsHeight }}>
<SettingsPanel onHeightChange={setSettingsPanelHeight} />
<BottomBar onClose={handleCloseSettings} onHeightChange={setBottomBarHeight} />
</div>
)}
<div
className={`prism-settings-region ${settingsVisible ? '' : 'is-hidden'}`.trim()}
style={{ height: settingsHeight }}
aria-hidden={!settingsVisible}
>
<SettingsPanel onHeightChange={setSettingsPanelHeight} />
<BottomBar onClose={handleCloseSettings} onHeightChange={setBottomBarHeight} />
</div>
<WindowResizeOverlay />
</div>
+15
View File
@@ -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)
}
+7
View File
@@ -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;
+164
View File
@@ -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<StackableWindowLike>,
): boolean {
if (!mainWindow || mainWindow.isDestroyed()) {
return false
}
for (const popout of popouts) {
if (!popout.isDestroyed() && !popout.isAlwaysOnTop()) {
mainWindow.moveTop()
return true
}
}
return false
}
+100
View File
@@ -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()