mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
fix macos issues, windows issues, themeing issues
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user