diff --git a/src/main/index.ts b/src/main/index.ts index 8ff3088..d0e67fe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ import { resolveNativeThemeSource } from '../shared/themeState' import { resolveWindowCapabilities } from '../shared/windowCapabilities' import { clampDraggedMainWindowBounds, + clampRestoredWindowBounds, raiseWindowAboveNormalPopouts, resolveExpandedMainWindowBounds, } from '../shared/windowGeometry' @@ -98,6 +99,7 @@ const NOW_PLAYING_CONFIG_DEFAULTS = { const STATIC_APP_ICON_FILENAME = 'icon.png' const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180 const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64 +const RESTORED_WINDOW_VISIBLE_MARGIN = 64 const runtimeWindowCapabilities = resolveWindowCapabilities({ platform: process.platform, argv: process.argv, @@ -582,7 +584,11 @@ function syncMainWindowLogicalBounds(window: BrowserWindow, bounds = window.getB } function applyMainWindowLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void { - const logicalBounds = normalizeMainWindowBounds(bounds) + const logicalBounds = clampRestoredWindowBounds( + normalizeMainWindowBounds(bounds), + getDisplayWorkAreas(), + RESTORED_WINDOW_VISIBLE_MARGIN, + ) mainWindowLogicalBounds = logicalBounds suppressMainWindowSync() const expandedBounds = resolveExpandedMainWindowBounds(logicalBounds, getSettingsHeight(window), getDisplayWorkAreas()) @@ -605,10 +611,12 @@ function applyLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void { return } - window.setBounds({ + const nextBounds = clampRestoredWindowBounds({ ...bounds, height: nextHeight, - }) + }, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN) + + window.setBounds(nextBounds) } function setWindowHeight(window: BrowserWindow, bounds: WindowBounds, height: number, y = bounds.y): void { @@ -1121,9 +1129,12 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro width: POPOUT_DEFAULTS.width, height: POPOUT_DEFAULTS.height, } - const bounds = shouldRestoreGeometry + const normalizedBounds = shouldRestoreGeometry ? normalizeBounds(rawBounds, fallbackBounds) : fallbackBounds + const bounds = shouldRestoreGeometry + ? clampRestoredWindowBounds(normalizedBounds, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN) + : normalizedBounds suppressNextPopoutBoundsEvents.add(kind) const options: BrowserWindowConstructorOptions = { @@ -1212,7 +1223,11 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void { if (supportsGeometryPersistence() && desired.bounds) { const currentBounds = popoutWindow.getBounds() - const nextBounds = normalizeBounds(desired.bounds, currentBounds) + const nextBounds = clampRestoredWindowBounds( + normalizeBounds(desired.bounds, currentBounds), + getDisplayWorkAreas(), + RESTORED_WINDOW_VISIBLE_MARGIN, + ) const hasBoundsDelta = currentBounds.x !== nextBounds.x || currentBounds.y !== nextBounds.y diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index c6df1fc..7cb14e5 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -60,6 +60,7 @@ const defaultTheme = resolveTheme(createDefaultTheme()) export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element { const [snapshot, setSnapshot] = useState | null>(null) const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false) + const [cursorInsideWindow, setCursorInsideWindow] = useState(false) const prevMiniSettingsOpenRef = useRef(false) const frameTarget = usePerformanceStore((s) => s.frameTarget) const miniSettingsOpen = useUiStore((s) => s.settingsOpen) @@ -78,6 +79,30 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) frameScheduler.setFrameTarget(frameTarget) }, [frameScheduler, frameTarget]) + useEffect(() => { + let isDisposed = false + + const syncCursorInsideWindow = (): void => { + void window.electronAPI.isCursorInsideWindow() + .then((isInside) => { + if (!isDisposed) { + setCursorInsideWindow(isInside) + } + }) + .catch(() => { + // Renderer pointer events still update the chrome when cursor polling is unavailable. + }) + } + + syncCursorInsideWindow() + const interval = setInterval(syncCursorInsideWindow, 120) + + return () => { + isDisposed = true + clearInterval(interval) + } + }, []) + useEffect(() => { const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => { if (nextSnapshot.kind !== scopeKind) return @@ -182,6 +207,9 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) return (
setCursorInsideWindow(true)} + onMouseMove={() => setCursorInsideWindow(true)} + onMouseLeave={() => setCursorInsideWindow(false)} onMouseDown={useNativeDragRegions ? undefined : handleAltDragStart} onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd} > @@ -193,6 +221,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) className={[ 'scope-popout__chrome', miniSettingsOpen ? 'is-expanded' : '', + cursorInsideWindow ? 'is-cursor-inside' : '', ].join(' ').trim()} >
diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index ddddf21..b21fe8c 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -30,6 +30,8 @@ const ACTIVE_PROFILE_KEY = 'prism:activeProfile' const PROFILE_GEOMETRY_SYNC_WINDOW_MS = 800 interface PersistedSettingsState { + activeProfileId?: string | null + profileBaselineSignature?: string scopeOrder: ScopeKind[] hiddenScopes: ScopeKind[] widthWeights: Record @@ -112,6 +114,29 @@ function buildPersistedScopePopouts(scopePopouts: ScopePopoutStateMap): ScopePop : stripScopePopoutBounds(normalizedScopePopouts) } +function buildProfileBaselineSignature(profile: Profile | null): string | undefined { + if (!profile) { + return undefined + } + + const normalizedProfile = normalizeProfile(profile, profile.name) + const scopePopouts = SCOPE_KINDS.reduce((acc, kind) => { + acc[kind] = { + poppedOut: normalizedProfile.scopePopouts[kind]?.poppedOut === true, + } + return acc + }, {} as Record) + + return JSON.stringify({ + name: normalizedProfile.name, + scopeOrder: normalizeScopeOrder(normalizedProfile.scopeOrder), + hiddenScopes: normalizeHiddenScopes(normalizedProfile.hiddenScopes), + widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights), + scopeSettings: mergeScopeSettings(normalizedProfile.scopeSettings), + scopePopouts, + }) +} + function restoreBaselineScopePopoutBounds( scopePopouts: ScopePopoutStateMap, baseline: Profile | null, @@ -144,6 +169,30 @@ function restoreBaselineGeometry( } } +function restoreBaselinePopoutOpenState( + state: Pick, + workingState: WorkingSettingsState, +): WorkingSettingsState { + const baseline = state.savedProfileBaseline + if (!baseline) { + return workingState + } + + const baselinePopouts = normalizeScopePopouts(baseline.scopePopouts) + return { + ...workingState, + scopePopouts: SCOPE_KINDS.reduce((acc, kind) => { + acc[kind] = { + ...workingState.scopePopouts[kind], + poppedOut: baselinePopouts[kind]?.poppedOut === true, + windowBounds: workingState.scopePopouts[kind]?.windowBounds + ?? baselinePopouts[kind]?.windowBounds, + } + return acc + }, {} as ScopePopoutStateMap), + } +} + function loadFromStorage(): Partial { if (!canUseBrowserStorage()) { return {} @@ -165,7 +214,9 @@ function loadFromStorage(): Partial { return {} } -function saveToStorage(state: WorkingSettingsState): void { +function saveToStorage( + state: WorkingSettingsState & Pick, +): void { if (!canUseBrowserStorage()) { return } @@ -177,6 +228,8 @@ function saveToStorage(state: WorkingSettingsState): void { : undefined localStorage.setItem(STORAGE_KEY, JSON.stringify({ + activeProfileId: state.activeProfileId, + profileBaselineSignature: buildProfileBaselineSignature(state.savedProfileBaseline), scopeOrder: state.scopeOrder, hiddenScopes: Array.from(state.hiddenScopes), widthWeights: state.widthWeights, @@ -189,7 +242,9 @@ function saveToStorage(state: WorkingSettingsState): void { } } -function persistWorkingState(state: WorkingSettingsState): void { +function persistWorkingState( + state: WorkingSettingsState & Pick, +): void { saveToStorage(state) } @@ -202,6 +257,26 @@ function hasPersistedWorkingState(state: Partial): boole || 'windowBounds' in state } +function canRestorePersistedWorkingState( + state: Partial, + snapshot: ProfileLibrarySnapshot, +): boolean { + if (!hasPersistedWorkingState(state)) { + return false + } + + if (!snapshot.activeProfileId || state.activeProfileId !== snapshot.activeProfileId) { + return false + } + + const activeProfile = snapshot.profiles[snapshot.activeProfileId] + if (!activeProfile || typeof state.profileBaselineSignature !== 'string') { + return false + } + + return state.profileBaselineSignature === buildProfileBaselineSignature(activeProfile) +} + function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null { try { const rawProfiles = localStorage.getItem(PROFILES_STORAGE_KEY) @@ -558,19 +633,18 @@ export const useSettingsStore = create((set, get) => ({ } } - // If localStorage has a working state from a previous session, preserve it so the - // user picks up exactly where they left off (dirty or not). The saved profile becomes - // the baseline for dirty-state comparison but the working values are left as-is. - // On first launch (no stored state) we load the profile normally. + // Preserve a previous working draft only when it belongs to the current active + // profile baseline. Legacy or stale state should not override saved popout state. const storedWorkingState = loadFromStorage() - const hasStoredWorkingState = hasPersistedWorkingState(storedWorkingState) - applyProfileSnapshot(set, snapshot, { loadActiveProfile: !hasStoredWorkingState }) - if (hasStoredWorkingState) { + const shouldRestoreWorkingState = canRestorePersistedWorkingState(storedWorkingState, snapshot) + applyProfileSnapshot(set, snapshot, { loadActiveProfile: !shouldRestoreWorkingState }) + if (shouldRestoreWorkingState) { set((state) => { - const nextWorkingState = restoreBaselineGeometry( + const persistedWorkingState = restoreBaselineGeometry( state, createWorkingStateFromPersistedState(storedWorkingState), ) + const nextWorkingState = restoreBaselinePopoutOpenState(state, persistedWorkingState) return commitWorkingState(state, nextWorkingState, state.savedProfileBaseline) }) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index d5ca3ac..b8af184 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1977,6 +1977,8 @@ button.toolbar__version:hover { .scope-popout__viewport:hover .scope-popout__chrome, .scope-popout__chrome:hover, +.scope-popout__chrome:focus-within, +.scope-popout__chrome.is-cursor-inside, .scope-popout__chrome.is-expanded { max-height: 58px; opacity: 1; diff --git a/src/shared/windowGeometry.ts b/src/shared/windowGeometry.ts index b048286..cbdbf2e 100644 --- a/src/shared/windowGeometry.ts +++ b/src/shared/windowGeometry.ts @@ -145,6 +145,15 @@ export function clampDraggedMainWindowBounds( return clampBoundsWithVisibleMargin(actualBounds, envelope, visibleMargin) } +export function clampRestoredWindowBounds( + bounds: WindowBounds, + workAreas: readonly WorkAreaBounds[], + visibleMargin: number, +): WindowBounds { + const envelope = buildDisplayEnvelope(bounds, bounds, workAreas) + return clampBoundsWithVisibleMargin(bounds, envelope, visibleMargin) +} + export function raiseWindowAboveNormalPopouts( mainWindow: StackableWindowLike | null, popouts: Iterable, diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 31f6d23..f756d9e 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -31,6 +31,7 @@ import { import { resolveWindowCapabilities } from '../src/shared/windowCapabilities' import { clampDraggedMainWindowBounds, + clampRestoredWindowBounds, raiseWindowAboveNormalPopouts, resolveExpandedMainWindowBounds, } from '../src/shared/windowGeometry' @@ -2906,6 +2907,19 @@ test('dragged main-window bounds keep a visible grab margin without sticking at assert.equal(clamped.y, 120) }) +test('restored window bounds clamp back to a visible display margin', () => { + const clamped = clampRestoredWindowBounds( + { x: 1800, y: 1400, width: 420, height: 240 }, + [{ x: 0, y: 0, width: 800, height: 600 }], + 64, + ) + + assert.equal(clamped.x, 736) + assert.equal(clamped.y, 536) + assert.equal(clamped.width, 420) + assert.equal(clamped.height, 240) +}) + test('raiseWindowAboveNormalPopouts raises the main window when an unpinned popout exists', () => { const main = createFakeStackableWindow() const normalPopout = createFakeStackableWindow(false) @@ -2989,19 +3003,16 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const restoredBounds: WindowBounds[] = [] + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } const dirtyBounds = { x: 44, y: 55, width: 900, height: 180 } const fakeWindow = installFakeElectronWindow({ - getProfileSnapshot: async () => { - const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } - - return { - activeProfileId: DEFAULT_PROFILE_ID, - profiles: { - [DEFAULT_PROFILE_ID]: profile, - }, - } - }, + getProfileSnapshot: async () => ({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: profile, + }, + }), getWindowBounds: async () => dirtyBounds, setWindowBounds: (bounds: WindowBounds) => { restoredBounds.push(bounds) @@ -3009,17 +3020,24 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th }) try { - const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) - profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } + useSettingsStore.getState().applyExternalProfileSnapshot({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: profile, + }, + }) + await Promise.resolve() + await Promise.resolve() + const rawStored = fakeStorage.getItem('prism:settings') + assert.ok(rawStored) + const stored = JSON.parse(rawStored) as Record fakeStorage.setItem('prism:settings', JSON.stringify({ - scopeOrder: profile.scopeOrder, - hiddenScopes: profile.hiddenScopes, - widthWeights: profile.widthWeights, - scopeSettings: profile.scopeSettings, - scopePopouts: profile.scopePopouts, + ...stored, windowBounds: dirtyBounds, })) + restoredBounds.length = 0 + useSettingsStore.setState(previousSettingsState) await useSettingsStore.getState().initializeProfiles() @@ -3154,6 +3172,193 @@ test('initializeProfiles ignores stale persisted theme-only state and loads the } }) +test('initializeProfiles ignores legacy persisted inline popouts and restores active profile popouts', async () => { + const previousSettingsState = useSettingsStore.getState() + const fakeStorage = installFakeLocalStorage() + const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 } + const fakeWindow = installFakeElectronWindow({ + getProfileSnapshot: async () => { + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + profile.scopePopouts.spectrum = { + poppedOut: true, + windowBounds: savedPopoutBounds, + } + + return { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: profile, + }, + } + }, + getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), + setWindowBounds: () => {}, + }) + + try { + const staleProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) + staleProfile.scopePopouts.spectrum = { + poppedOut: false, + windowBounds: savedPopoutBounds, + } + fakeStorage.setItem('prism:settings', JSON.stringify({ + scopeOrder: staleProfile.scopeOrder, + hiddenScopes: staleProfile.hiddenScopes, + widthWeights: staleProfile.widthWeights, + scopeSettings: staleProfile.scopeSettings, + scopePopouts: staleProfile.scopePopouts, + })) + + await useSettingsStore.getState().initializeProfiles() + + const state = useSettingsStore.getState() + assert.equal(state.scopePopouts.spectrum.poppedOut, true) + assert.deepEqual(state.scopePopouts.spectrum.windowBounds, savedPopoutBounds) + assert.equal(state.hasUnsavedProfileChanges, false) + } finally { + useSettingsStore.setState(previousSettingsState) + fakeWindow.restore() + fakeStorage.restore() + } +}) + +test('initializeProfiles restores saved popout open state while preserving matching dirty draft values', async () => { + const previousSettingsState = useSettingsStore.getState() + const fakeStorage = installFakeLocalStorage() + const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 } + const dirtyPopoutBounds = { x: 180, y: 72, width: 440, height: 260 } + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + profile.scopePopouts.spectrum = { + poppedOut: true, + windowBounds: savedPopoutBounds, + } + const fakeWindow = installFakeElectronWindow({ + getProfileSnapshot: async () => ({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: profile, + }, + }), + getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), + setWindowBounds: () => {}, + }) + + try { + useSettingsStore.getState().applyExternalProfileSnapshot({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: profile, + }, + }) + await Promise.resolve() + await Promise.resolve() + + const rawStored = fakeStorage.getItem('prism:settings') + assert.ok(rawStored) + const stored = JSON.parse(rawStored) as { + activeProfileId?: string | null + profileBaselineSignature?: string + scopePopouts: ScopePopoutStateMap + } + assert.equal(stored.activeProfileId, DEFAULT_PROFILE_ID) + assert.equal(typeof stored.profileBaselineSignature, 'string') + + fakeStorage.setItem('prism:settings', JSON.stringify({ + ...stored, + scopeSettings: { + ...profile.scopeSettings, + spectrum: { + ...profile.scopeSettings.spectrum, + smoothing: 0.42, + }, + }, + scopePopouts: { + ...stored.scopePopouts, + spectrum: { + poppedOut: false, + windowBounds: dirtyPopoutBounds, + }, + }, + })) + useSettingsStore.setState(previousSettingsState) + + await useSettingsStore.getState().initializeProfiles() + + const state = useSettingsStore.getState() + assert.equal(state.scopePopouts.spectrum.poppedOut, true) + assert.deepEqual(state.scopePopouts.spectrum.windowBounds, dirtyPopoutBounds) + assert.equal(state.scopeSettings.spectrum.smoothing, 0.42) + assert.equal(state.hasUnsavedProfileChanges, true) + } finally { + useSettingsStore.setState(previousSettingsState) + fakeWindow.restore() + fakeStorage.restore() + } +}) + +test('initializeProfiles discards persisted popout state when the saved profile baseline changed', async () => { + const previousSettingsState = useSettingsStore.getState() + const fakeStorage = installFakeLocalStorage() + const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 } + const originalProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) + const changedProfile = createDefaultProfile(DEFAULT_PROFILE_NAME) + changedProfile.scopePopouts.spectrum = { + poppedOut: true, + windowBounds: savedPopoutBounds, + } + let snapshotProfile = originalProfile + const fakeWindow = installFakeElectronWindow({ + getProfileSnapshot: async () => ({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: snapshotProfile, + }, + }), + getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }), + setWindowBounds: () => {}, + }) + + try { + useSettingsStore.getState().applyExternalProfileSnapshot({ + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: originalProfile, + }, + }) + await Promise.resolve() + await Promise.resolve() + + const rawStored = fakeStorage.getItem('prism:settings') + assert.ok(rawStored) + const stored = JSON.parse(rawStored) as { + scopePopouts: ScopePopoutStateMap + } + fakeStorage.setItem('prism:settings', JSON.stringify({ + ...stored, + scopePopouts: { + ...stored.scopePopouts, + spectrum: { + poppedOut: false, + windowBounds: { x: 180, y: 72, width: 440, height: 260 }, + }, + }, + })) + snapshotProfile = changedProfile + useSettingsStore.setState(previousSettingsState) + + await useSettingsStore.getState().initializeProfiles() + + const state = useSettingsStore.getState() + assert.equal(state.scopePopouts.spectrum.poppedOut, true) + assert.deepEqual(state.scopePopouts.spectrum.windowBounds, savedPopoutBounds) + assert.equal(state.hasUnsavedProfileChanges, false) + } finally { + useSettingsStore.setState(previousSettingsState) + fakeWindow.restore() + fakeStorage.restore() + } +}) + test('profiles without saved window bounds mark the first user move dirty after load sync completes', async () => { const previousSettingsState = useSettingsStore.getState() const currentBounds = { x: 10, y: 20, width: 900, height: 180 }