fix macos issues, windows issues, themeing issues

This commit is contained in:
Boof2015
2026-03-29 17:49:49 -04:00
parent 436c45fd3a
commit 6cd30cac39
7 changed files with 567 additions and 48 deletions
+17
View File
@@ -35,10 +35,12 @@ let mainWindowBoundsTimer: ReturnType<typeof setTimeout> | null = null
let mainRendererReady = false
let allowMainWindowClose = false
let mainWindowClosePending = false
let suppressNextMainWindowBoundsEvent = false
const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
const popoutBoundsTimers = new Map<ScopeKind, ReturnType<typeof setTimeout>>()
const suppressNextPopoutBoundsEvents = new Set<ScopeKind>()
const windowSettingsHeights = new Map<number, number>()
const windowSettingsBottomAnchors = new Map<number, number>()
const pendingProfileOpenPaths: string[] = []
@@ -189,6 +191,10 @@ function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
mainWindowBoundsTimer = setTimeout(() => {
mainWindowBoundsTimer = null
if (window.isDestroyed() || window.webContents.isDestroyed()) return
if (suppressNextMainWindowBoundsEvent) {
suppressNextMainWindowBoundsEvent = false
return
}
window.webContents.send('window:bounds-changed', toLogicalBounds(window))
}, 80)
}
@@ -565,6 +571,10 @@ function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
popoutBoundsTimers.delete(kind)
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
if (suppressNextPopoutBoundsEvents.has(kind)) {
suppressNextPopoutBoundsEvents.delete(kind)
return
}
const bounds = toLogicalBounds(window)
mainWindow.webContents.send('scope-popout:bounds-changed', kind, bounds)
}, 80)
@@ -582,11 +592,13 @@ function destroyScopePopoutWindow(kind: ScopeKind): void {
popoutBoundsTimers.delete(kind)
}
suppressNextPopoutBoundsEvents.delete(kind)
scopePopoutCloseAllowed.add(kind)
scopePopoutWindows.delete(kind)
if (!window.isDestroyed()) {
window.close()
return
}
scopePopoutCloseAllowed.delete(kind)
@@ -608,6 +620,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
height: POPOUT_DEFAULTS.height,
}
const bounds = normalizeBounds(rawBounds, fallbackBounds)
suppressNextPopoutBoundsEvents.add(kind)
const options: BrowserWindowConstructorOptions = {
x: bounds.x,
@@ -697,6 +710,7 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
|| currentBounds.height !== nextBounds.height
if (hasBoundsDelta) {
suppressNextPopoutBoundsEvents.add(kind)
applyLogicalBounds(popoutWindow, nextBounds)
}
}
@@ -1044,6 +1058,9 @@ function setupIPC(): void {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
if (isMainRendererWindow(targetWindow)) {
suppressNextMainWindowBoundsEvent = true
}
applyLogicalBounds(targetWindow, bounds)
})
+25 -2
View File
@@ -1,8 +1,9 @@
import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX } from 'react'
import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX, type WheelEvent } from 'react'
import { useAudioStore } from '../stores/audioStore'
import { usePerformanceStore } from '../stores/performanceStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useThemeStore } from '../stores/themeStore'
import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll'
import type { ScopeKind } from '../../types/scope'
import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance'
import { SCOPE_KINDS } from '../../types/scope'
@@ -167,9 +168,31 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
setThemeId(useThemeStore.getState().activeThemeId)
}
const handleRailWheel = (event: WheelEvent<HTMLDivElement>): void => {
const railElement = event.currentTarget
const target = event.target
const isTargetExcluded = target instanceof Element
&& target.closest('input[type="range"], select, .settings-control__select') !== null
const scrollResult = getHorizontalWheelScrollResult({
clientWidth: railElement.clientWidth,
deltaMode: event.deltaMode,
deltaX: event.deltaX,
deltaY: event.deltaY,
isTargetExcluded,
scrollLeft: railElement.scrollLeft,
scrollWidth: railElement.scrollWidth,
})
if (!scrollResult) return
railElement.scrollLeft = scrollResult.nextScrollLeft
event.preventDefault()
}
return (
<div className="bottom-bar" ref={rootRef}>
<div className="bottom-bar__rail" aria-label="Global settings">
<div className="bottom-bar__rail" aria-label="Global settings" onWheel={handleRailWheel}>
<div className="bottom-bar__rail-content">
<section className="bottom-bar__section bottom-bar__section--modules">
<div className="bottom-bar__section-title">Modules</div>
+1 -2
View File
@@ -21,11 +21,10 @@ export interface ProfileDraftSource {
export function buildProfileDraft(
source: ProfileDraftSource,
name: string,
fallbackThemeId: string | null = null,
): Profile {
return normalizeProfile({
name,
themeId: source.themeId ?? fallbackThemeId,
themeId: source.themeId,
scopeOrder: [...source.scopeOrder],
hiddenScopes: Array.from(source.hiddenScopes),
widthWeights: { ...source.widthWeights },
+100 -24
View File
@@ -26,6 +26,7 @@ export type { ScopeSettings } from '../../types/settings'
const STORAGE_KEY = 'prism:settings'
const PROFILES_STORAGE_KEY = 'prism:profiles'
const ACTIVE_PROFILE_KEY = 'prism:activeProfile'
const PROFILE_GEOMETRY_SYNC_WINDOW_MS = 800
interface PersistedSettingsState {
themeId: string | null
@@ -53,6 +54,7 @@ interface SettingsState extends WorkingSettingsState {
activeProfileId: string | null
savedProfileBaseline: Profile | null
hasUnsavedProfileChanges: boolean
geometrySyncUntil: number
initializeProfiles: () => Promise<void>
applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => void
setThemeId: (themeId: string | null) => void
@@ -190,11 +192,7 @@ function buildActiveProfileDraft(state: SettingsState): Profile | null {
return null
}
return buildProfileDraft(
state,
activeProfileName,
useThemeStore.getState().activeThemeId,
)
return buildProfileDraft(state, activeProfileName)
}
export function hasProfileDraftChanges(state: SettingsState, baseline = state.savedProfileBaseline): boolean {
@@ -232,16 +230,32 @@ function commitWorkingState(
return nextState
}
function nextGeometrySyncDeadline(): number {
return Date.now() + PROFILE_GEOMETRY_SYNC_WINDOW_MS
}
function isWithinGeometrySyncWindow(state: Pick<SettingsState, 'geometrySyncUntil'>): boolean {
return state.geometrySyncUntil > Date.now()
}
function syncMissingBaselineWindowBounds(state: SettingsState, bounds: WindowBounds): Profile | null {
const baseline = state.savedProfileBaseline
if (!baseline || state.hasUnsavedProfileChanges || baseline.windowBounds) {
if (!baseline || state.hasUnsavedProfileChanges) {
return baseline
}
return normalizeProfile({
...baseline,
windowBounds: bounds,
}, baseline.name)
if (isWithinGeometrySyncWindow(state)) {
return normalizeProfile({
...baseline,
windowBounds: bounds,
}, baseline.name)
}
if (baseline.windowBounds) {
return baseline
}
return baseline
}
function syncMissingBaselinePopoutBounds(
@@ -251,20 +265,28 @@ function syncMissingBaselinePopoutBounds(
): Profile | null {
const baseline = state.savedProfileBaseline
const baselinePopout = baseline?.scopePopouts[kind]
if (!baseline || state.hasUnsavedProfileChanges || !baselinePopout?.poppedOut || baselinePopout.windowBounds) {
if (!baseline || state.hasUnsavedProfileChanges || !baselinePopout?.poppedOut) {
return baseline
}
return normalizeProfile({
...baseline,
scopePopouts: {
...baseline.scopePopouts,
[kind]: {
...baselinePopout,
windowBounds: bounds,
if (isWithinGeometrySyncWindow(state)) {
return normalizeProfile({
...baseline,
scopePopouts: {
...baseline.scopePopouts,
[kind]: {
...baselinePopout,
windowBounds: bounds,
},
},
},
}, baseline.name)
}, baseline.name)
}
if (baselinePopout.windowBounds) {
return baseline
}
return baseline
}
function applyLoadedProfileEffects(profile: Profile | null): void {
@@ -282,6 +304,39 @@ function applyLoadedProfileEffects(profile: Profile | null): void {
}
}
function syncCurrentMainWindowBounds(
set: (updater: (state: SettingsState) => SettingsState) => void,
): void {
if (!canUseElectronAPI()) {
return
}
void window.electronAPI.getWindowBounds().then((bounds) => {
if (!bounds) {
return
}
set((state) => {
const baseline = state.savedProfileBaseline
if (!baseline || state.hasUnsavedProfileChanges) {
return state
}
const nextState = commitWorkingState(state, {
windowBounds: bounds,
}, normalizeProfile({
...baseline,
windowBounds: bounds,
}, baseline.name))
return {
...nextState,
geometrySyncUntil: 0,
}
})
})
}
function applyProfileSnapshot(
set: (updater: (state: SettingsState) => SettingsState) => void,
snapshot: ProfileLibrarySnapshot,
@@ -300,6 +355,7 @@ function applyProfileSnapshot(
...state,
profiles: snapshot.profiles,
activeProfileId: snapshot.activeProfileId,
geometrySyncUntil: 0,
}, baselineProfile)
}
@@ -309,6 +365,7 @@ function applyProfileSnapshot(
...nextWorkingState,
profiles: snapshot.profiles,
activeProfileId: snapshot.activeProfileId,
geometrySyncUntil: nextGeometrySyncDeadline(),
}, baselineProfile)
persistWorkingState(nextState)
@@ -317,6 +374,7 @@ function applyProfileSnapshot(
if (options.loadActiveProfile) {
applyLoadedProfileEffects(activeProfile)
syncCurrentMainWindowBounds(set)
}
}
@@ -386,11 +444,14 @@ async function restoreSavedProfileBaseline(
const nextState = withProfileDraftState({
...state,
...nextWorkingState,
geometrySyncUntil: nextGeometrySyncDeadline(),
}, baseline)
persistWorkingState(nextState)
return nextState
})
syncCurrentMainWindowBounds(set)
}
const stored = canUseElectronAPI() ? {} : loadFromStorage()
@@ -414,6 +475,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
activeProfileId: null,
savedProfileBaseline: null,
hasUnsavedProfileChanges: false,
geometrySyncUntil: 0,
visibleScopes: () => {
const { scopeOrder, hiddenScopes } = get()
@@ -528,8 +590,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => {
set((state) => {
const isSyncingGeometry = isWithinGeometrySyncWindow(state)
const nextBaseline = syncMissingBaselinePopoutBounds(state, kind, bounds)
return commitWorkingState(state, {
const nextState = commitWorkingState(state, {
scopePopouts: {
...state.scopePopouts,
[kind]: {
@@ -538,13 +601,26 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
},
},
}, nextBaseline)
return isSyncingGeometry
? {
...nextState,
geometrySyncUntil: nextGeometrySyncDeadline(),
}
: nextState
})
},
updateMainWindowBounds: (bounds: WindowBounds) => {
set((state) => {
const isSyncingGeometry = isWithinGeometrySyncWindow(state)
const nextBaseline = syncMissingBaselineWindowBounds(state, bounds)
return commitWorkingState(state, { windowBounds: bounds }, nextBaseline)
const nextState = commitWorkingState(state, { windowBounds: bounds }, nextBaseline)
return isSyncingGeometry
? {
...nextState,
geometrySyncUntil: nextGeometrySyncDeadline(),
}
: nextState
})
},
@@ -584,7 +660,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const snapshot = await window.electronAPI.saveNewProfile(
name,
buildProfileDraft(get(), name, useThemeStore.getState().activeThemeId),
buildProfileDraft(get(), name),
)
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
return snapshot.activeProfileId
@@ -604,7 +680,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const snapshot = await window.electronAPI.overwriteProfile(
id,
buildProfileDraft(state, name, useThemeStore.getState().activeThemeId),
buildProfileDraft(state, name),
)
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
},
+36 -18
View File
@@ -191,7 +191,7 @@ select {
background:
linear-gradient(180deg, var(--settings-bg-top), var(--settings-bg-bottom)),
rgba(0, 0, 0, 0.96);
border-top: 1px solid var(--divider);
border-top: 1px solid rgba(var(--accent-rgb), 0.14);
box-shadow: 0 -16px 34px rgba(0, 0, 0, 0.34);
}
@@ -239,8 +239,9 @@ select {
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--control-border);
background: var(--glass-bg);
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
}
.toolbar__brand-mark {
@@ -339,7 +340,7 @@ select {
display: flex;
flex-direction: column;
background: var(--menu-bg);
border: 1px solid var(--menu-border);
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 8px;
overflow: hidden;
z-index: 20;
@@ -629,7 +630,7 @@ select {
flex-direction: column;
gap: 12px;
padding: 0 16px 14px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
border-left: 1px solid rgba(var(--accent-rgb), 0.12);
}
.settings-scope-section:first-child {
@@ -643,7 +644,7 @@ select {
gap: 12px;
padding: 10px 0 12px;
min-height: 42px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid rgba(var(--accent-rgb), 0.14);
}
.settings-scope-section__title {
@@ -713,9 +714,9 @@ select {
padding: 0 12px;
padding-right: 36px;
border-radius: 10px;
border: 1px solid transparent;
background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
border: 1px solid rgba(var(--accent-rgb), 0.2);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.1), rgba(7, 10, 15, 0.96));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
color: var(--text-primary);
font-size: 11px;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, color 140ms ease;
@@ -723,8 +724,8 @@ select {
.settings-control__select:hover,
.settings-control__select:focus-within {
border-color: var(--input-border-focus);
background: linear-gradient(180deg, var(--input-bg-focus), var(--settings-bg-bottom));
border-color: rgba(var(--accent-rgb), 0.34);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(8, 12, 18, 0.98));
color: var(--text-primary);
}
@@ -918,8 +919,9 @@ select {
min-height: 32px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid var(--control-border);
background: linear-gradient(180deg, var(--input-bg), var(--settings-bg-bottom));
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(7, 10, 15, 0.96));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
color: var(--text-secondary);
font-size: 10px;
white-space: nowrap;
@@ -937,16 +939,31 @@ select {
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.3);
}
.settings-status-pill.is-connecting {
border-color: rgba(var(--accent-rgb), 0.32);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.16), rgba(7, 10, 15, 0.96));
}
.settings-status-pill.is-capturing .settings-status-pill__dot {
background: var(--success);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
}
.settings-status-pill.is-capturing {
border-color: rgba(34, 197, 94, 0.34);
background: linear-gradient(180deg, rgba(34, 197, 94, 0.14), rgba(7, 10, 15, 0.96));
}
.settings-status-pill.is-error .settings-status-pill__dot {
background: var(--danger);
box-shadow: 0 0 8px rgba(248, 113, 113, 0.36);
}
.settings-status-pill.is-error {
border-color: rgba(248, 113, 113, 0.34);
background: linear-gradient(180deg, rgba(248, 113, 113, 0.14), rgba(7, 10, 15, 0.96));
}
.settings-error-text {
margin-top: 8px;
color: var(--danger);
@@ -1034,7 +1051,7 @@ select {
min-height: 92px;
padding: 0;
background: var(--bottom-bar-bg);
border-top: 1px solid var(--divider);
border-top: 1px solid rgba(var(--accent-rgb), 0.14);
flex-shrink: 0;
}
@@ -1112,8 +1129,9 @@ select {
gap: 4px;
padding: 4px;
border-radius: 12px;
border: 1px solid var(--control-border);
background: var(--glass-bg);
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.08), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(var(--accent-rgb), 0.08);
}
.bottom-bar__inline--theme {
@@ -1128,7 +1146,7 @@ select {
width: 1px;
align-self: stretch;
margin: 0 16px 0 0;
background: linear-gradient(180deg, transparent, var(--divider) 18%, rgba(255, 255, 255, 0.04) 82%, transparent);
background: linear-gradient(180deg, transparent, rgba(var(--accent-rgb), 0.18) 18%, rgba(var(--accent-rgb), 0.08) 82%, transparent);
flex-shrink: 0;
}
@@ -1175,7 +1193,7 @@ select {
align-items: center;
flex: 0 0 auto;
padding: 10px 14px 10px 16px;
border-left: 1px solid var(--divider);
border-left: 1px solid rgba(var(--accent-rgb), 0.14);
background: linear-gradient(90deg, rgba(2, 4, 7, 0.76), var(--bottom-bar-bg) 24%, var(--bottom-bar-bg));
}
@@ -0,0 +1,54 @@
const WHEEL_DELTA_LINE_PX = 16
const WHEEL_DELTA_PAGE_WIDTH_FACTOR = 0.9
export interface HorizontalWheelScrollInput {
clientWidth: number
deltaMode: number
deltaX: number
deltaY: number
isTargetExcluded?: boolean
scrollLeft: number
scrollWidth: number
}
export interface HorizontalWheelScrollResult {
appliedDelta: number
nextScrollLeft: number
}
export function normalizeWheelDelta(delta: number, deltaMode: number, clientWidth: number): number {
if (deltaMode === 1) {
return delta * WHEEL_DELTA_LINE_PX
}
if (deltaMode === 2) {
return delta * clientWidth * WHEEL_DELTA_PAGE_WIDTH_FACTOR
}
return delta
}
export function getHorizontalWheelScrollResult({
clientWidth,
deltaMode,
deltaX,
deltaY,
isTargetExcluded = false,
scrollLeft,
scrollWidth,
}: HorizontalWheelScrollInput): HorizontalWheelScrollResult | null {
const maxScrollLeft = Math.max(0, scrollWidth - clientWidth)
if (isTargetExcluded || maxScrollLeft <= 0) return null
if (deltaX !== 0 || deltaY === 0) return null
const normalizedDelta = normalizeWheelDelta(deltaY, deltaMode, clientWidth)
if (normalizedDelta === 0) return null
const nextScrollLeft = Math.min(maxScrollLeft, Math.max(0, scrollLeft + normalizedDelta))
if (nextScrollLeft === scrollLeft) return null
return {
appliedDelta: nextScrollLeft - scrollLeft,
nextScrollLeft,
}
}
+334 -2
View File
@@ -6,6 +6,10 @@ import {
parseColorToRgb,
resolveColorToRgb,
} from '../src/renderer/utils/color'
import {
getHorizontalWheelScrollResult,
normalizeWheelDelta,
} from '../src/renderer/utils/horizontalWheelScroll'
import {
createDefaultProfile,
} from '../src/shared/profileState'
@@ -251,7 +255,7 @@ function installFakeLocalStorage(): {
}
}
function installFakeElectronWindow(): {
function installFakeElectronWindow(overrides: Record<string, unknown> = {}): {
restore: () => void
} {
const globalWithWindow = globalThis as typeof globalThis & { window?: WindowWithTimers }
@@ -259,7 +263,10 @@ function installFakeElectronWindow(): {
globalWithWindow.window = {
...globalThis,
electronAPI: { platform: 'darwin' },
electronAPI: {
platform: 'darwin',
...overrides,
},
} as WindowWithTimers
return {
@@ -677,6 +684,127 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer
assert.equal(options.lineColor, theme.spectrum.primary)
})
test('normalizeWheelDelta keeps pixel deltas unchanged', () => {
assert.equal(normalizeWheelDelta(24, 0, 320), 24)
})
test('normalizeWheelDelta converts line deltas to pixels', () => {
assert.equal(normalizeWheelDelta(3, 1, 320), 48)
})
test('normalizeWheelDelta scales page deltas to viewport width', () => {
assert.equal(normalizeWheelDelta(2, 2, 500), 900)
})
test('getHorizontalWheelScrollResult converts vertical wheel input into horizontal movement', () => {
assert.deepEqual(
getHorizontalWheelScrollResult({
clientWidth: 320,
deltaMode: 0,
deltaX: 0,
deltaY: 60,
scrollLeft: 40,
scrollWidth: 960,
}),
{
appliedDelta: 60,
nextScrollLeft: 100,
},
)
})
test('getHorizontalWheelScrollResult leaves native horizontal wheel input alone', () => {
assert.equal(
getHorizontalWheelScrollResult({
clientWidth: 320,
deltaMode: 0,
deltaX: 8,
deltaY: 40,
scrollLeft: 40,
scrollWidth: 960,
}),
null,
)
})
test('getHorizontalWheelScrollResult is a no-op when the rail does not overflow', () => {
assert.equal(
getHorizontalWheelScrollResult({
clientWidth: 320,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
scrollLeft: 0,
scrollWidth: 320,
}),
null,
)
})
test('getHorizontalWheelScrollResult is a no-op for excluded interactive controls', () => {
assert.equal(
getHorizontalWheelScrollResult({
clientWidth: 320,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
isTargetExcluded: true,
scrollLeft: 0,
scrollWidth: 960,
}),
null,
)
})
test('getHorizontalWheelScrollResult moves scrollLeft left for negative deltas', () => {
assert.deepEqual(
getHorizontalWheelScrollResult({
clientWidth: 320,
deltaMode: 0,
deltaX: 0,
deltaY: -50,
scrollLeft: 120,
scrollWidth: 960,
}),
{
appliedDelta: -50,
nextScrollLeft: 70,
},
)
})
test('getHorizontalWheelScrollResult normalizes line and page delta modes', () => {
assert.deepEqual(
getHorizontalWheelScrollResult({
clientWidth: 400,
deltaMode: 1,
deltaX: 0,
deltaY: 2,
scrollLeft: 10,
scrollWidth: 1200,
}),
{
appliedDelta: 32,
nextScrollLeft: 42,
},
)
assert.deepEqual(
getHorizontalWheelScrollResult({
clientWidth: 400,
deltaMode: 2,
deltaX: 0,
deltaY: 1,
scrollLeft: 10,
scrollWidth: 1200,
}),
{
appliedDelta: 360,
nextScrollLeft: 370,
},
)
})
test('scopeSettingsToOptions wires waveform stereo mode into analyzer options', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.waveform.mode = 'stereo'
@@ -805,6 +933,23 @@ test('profile draft comparisons return to clean after reverting a change', () =>
assert.equal(profilesMatch(baselineDraft, revertedDraft), true)
})
test('buildProfileDraft preserves unlinked themes instead of coercing the active theme', () => {
const profile = createDefaultProfile('Live Mix')
profile.themeId = null
const draft = buildProfileDraft({
themeId: profile.themeId,
scopeOrder: profile.scopeOrder,
hiddenScopes: profile.hiddenScopes,
widthWeights: profile.widthWeights,
scopeSettings: profile.scopeSettings,
scopePopouts: profile.scopePopouts,
windowBounds: profile.windowBounds,
}, profile.name)
assert.equal(draft.themeId, null)
})
test('main-window bounds updates stay in memory in Electron mode until save', () => {
const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage()
@@ -813,6 +958,7 @@ test('main-window bounds updates stay in memory in Electron mode until save', ()
try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.themeId = 'theme_default'
profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 }
seedProfileDraftState(profile)
useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
@@ -833,6 +979,191 @@ test('main-window bounds updates stay in memory in Electron mode until save', ()
}
})
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 }
const fakeWindow = installFakeElectronWindow({
getWindowBounds: async () => currentBounds,
setWindowBounds: () => {},
})
try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.themeId = 'theme_default'
useSettingsStore.getState().applyExternalProfileSnapshot({
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
})
await Promise.resolve()
await Promise.resolve()
assert.deepEqual(useSettingsStore.getState().savedProfileBaseline?.windowBounds, currentBounds)
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 })
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
}
})
test('loading a profile syncs the live window bounds without marking the draft dirty', async () => {
const previousSettingsState = useSettingsStore.getState()
const currentBounds = { x: 52, y: 18, width: 940, height: 192 }
const fakeWindow = installFakeElectronWindow({
getWindowBounds: async () => currentBounds,
setWindowBounds: () => {},
})
try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.themeId = 'theme_default'
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()
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
assert.deepEqual(useSettingsStore.getState().windowBounds, currentBounds)
assert.deepEqual(useSettingsStore.getState().savedProfileBaseline?.windowBounds, currentBounds)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
}
})
test('switching to a non-default profile with an unlinked theme does not mark it dirty', async () => {
const previousSettingsState = useSettingsStore.getState()
const fakeWindow = installFakeElectronWindow({
getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }),
setWindowBounds: () => {},
})
try {
const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME)
defaultProfile.themeId = 'theme_default'
const liveMixProfile = createDefaultProfile('Live Mix')
liveMixProfile.themeId = null
useSettingsStore.getState().applyExternalProfileSnapshot({
activeProfileId: 'profile_live_mix',
profiles: {
[DEFAULT_PROFILE_ID]: defaultProfile,
profile_live_mix: liveMixProfile,
},
})
await Promise.resolve()
await Promise.resolve()
assert.equal(useSettingsStore.getState().themeId, null)
assert.equal(useSettingsStore.getState().savedProfileBaseline?.themeId, null)
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
}
})
test('profile load absorbs immediate macOS-style window bound adjustments without marking dirty', async () => {
const previousSettingsState = useSettingsStore.getState()
const fakeWindow = installFakeElectronWindow({
getWindowBounds: async () => ({ x: 16, y: 18, width: 900, height: 180 }),
setWindowBounds: () => {},
})
try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.themeId = 'theme_default'
profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 }
useSettingsStore.getState().applyExternalProfileSnapshot({
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
})
useSettingsStore.getState().updateMainWindowBounds({ x: 16, y: 18, width: 900, height: 180 })
await Promise.resolve()
await Promise.resolve()
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
}
})
test('profile load absorbs immediate popout bound adjustments without marking dirty', async () => {
const previousSettingsState = useSettingsStore.getState()
const fakeWindow = installFakeElectronWindow({
getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }),
setWindowBounds: () => {},
})
try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.themeId = 'theme_default'
profile.scopePopouts.spectrum = {
poppedOut: true,
windowBounds: { x: 140, y: 60, width: 420, height: 240 },
}
useSettingsStore.getState().applyExternalProfileSnapshot({
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
})
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 156, y: 58, width: 420, height: 240 })
await Promise.resolve()
await Promise.resolve()
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
}
})
test('geometry sync window extends while load-time macOS bound updates continue', () => {
const previousSettingsState = useSettingsStore.getState()
const fakeWindow = installFakeElectronWindow()
try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.themeId = 'theme_default'
seedProfileDraftState(profile)
useSettingsStore.setState((state) => ({
...state,
geometrySyncUntil: Date.now() + 50,
}))
const before = useSettingsStore.getState().geometrySyncUntil
useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 18, width: 900, height: 180 })
assert.ok(useSettingsStore.getState().geometrySyncUntil > before)
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
}
})
test('popout bounds updates stay in memory in Electron mode until save', () => {
const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage()
@@ -843,6 +1174,7 @@ test('popout bounds updates stay in memory in Electron mode until save', () => {
profile.themeId = 'theme_default'
profile.scopePopouts.spectrum = {
poppedOut: true,
windowBounds: { x: 140, y: 60, width: 420, height: 240 },
}
seedProfileDraftState(profile)