diff --git a/src/main/index.ts b/src/main/index.ts index f5122f9..10ddbba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,10 +35,12 @@ let mainWindowBoundsTimer: ReturnType | null = null let mainRendererReady = false let allowMainWindowClose = false let mainWindowClosePending = false +let suppressNextMainWindowBoundsEvent = false const scopePopoutWindows = new Map() const scopePopoutCloseAllowed = new Set() const popoutBoundsTimers = new Map>() +const suppressNextPopoutBoundsEvents = new Set() const windowSettingsHeights = new Map() const windowSettingsBottomAnchors = new Map() 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) }) diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 07e49eb..3a8840e 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -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): 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 (
-
+
Modules
diff --git a/src/renderer/stores/profileDraft.ts b/src/renderer/stores/profileDraft.ts index 5cc68c0..91f8ab6 100644 --- a/src/renderer/stores/profileDraft.ts +++ b/src/renderer/stores/profileDraft.ts @@ -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 }, diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index ebd104b..2244c4a 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -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 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): 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((set, get) => ({ activeProfileId: null, savedProfileBaseline: null, hasUnsavedProfileChanges: false, + geometrySyncUntil: 0, visibleScopes: () => { const { scopeOrder, hiddenScopes } = get() @@ -528,8 +590,9 @@ export const useSettingsStore = create((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((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((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((set, get) => ({ const snapshot = await window.electronAPI.overwriteProfile( id, - buildProfileDraft(state, name, useThemeStore.getState().activeThemeId), + buildProfileDraft(state, name), ) applyProfileSnapshot(set, snapshot, { loadActiveProfile: false }) }, diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 59386c3..2f21f54 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -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)); } diff --git a/src/renderer/utils/horizontalWheelScroll.ts b/src/renderer/utils/horizontalWheelScroll.ts new file mode 100644 index 0000000..2c1ef4e --- /dev/null +++ b/src/renderer/utils/horizontalWheelScroll.ts @@ -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, + } +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 89cf05e..9a355b6 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -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 = {}): { 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)