Files
modeling-app/src/lib/settings/settingsUtils.ts

360 lines
11 KiB
TypeScript

import { Setting, createSettings, settings } from 'lib/settings/initialSettings'
import { SaveSettingsPayload, SettingsLevel } from './settingsTypes'
import { isDesktop } from 'lib/isDesktop'
import { err } from 'lib/trap'
import {
defaultAppSettings,
defaultProjectSettings,
initPromise,
parseAppSettings,
parseProjectSettings,
tomlStringify,
} from 'lang/wasm'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { mouseControlsToCameraSystem } from 'lib/cameraControls'
import { appThemeToTheme } from 'lib/theme'
import {
getInitialDefaultDir,
readAppSettingsFile,
readProjectSettingsFile,
writeAppSettingsFile,
writeProjectSettingsFile,
} from 'lib/desktop'
import { ProjectConfiguration } from 'wasm-lib/kcl/bindings/ProjectConfiguration'
import { BROWSER_PROJECT_NAME } from 'lib/constants'
import { DeepPartial } from 'lib/types'
/**
* Convert from a rust settings struct into the JS settings struct.
* We do this because the JS settings type has all the fancy shit
* for hiding and showing settings.
**/
export function configurationToSettingsPayload(
configuration: DeepPartial<Configuration>
): DeepPartial<SaveSettingsPayload> {
return {
app: {
theme: appThemeToTheme(configuration?.settings?.app?.appearance?.theme),
themeColor: configuration?.settings?.app?.appearance?.color
? configuration?.settings?.app?.appearance?.color.toString()
: undefined,
onboardingStatus: configuration?.settings?.app?.onboarding_status,
dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner,
streamIdleMode: configuration?.settings?.app?.stream_idle_mode,
projectDirectory: configuration?.settings?.project?.directory,
enableSSAO: configuration?.settings?.modeling?.enable_ssao,
},
modeling: {
defaultUnit: configuration?.settings?.modeling?.base_unit,
cameraProjection: configuration?.settings?.modeling?.camera_projection,
mouseControls: mouseControlsToCameraSystem(
configuration?.settings?.modeling?.mouse_controls
),
highlightEdges: configuration?.settings?.modeling?.highlight_edges,
showDebugPanel: configuration?.settings?.modeling?.show_debug_panel,
showScaleGrid: configuration?.settings?.modeling?.show_scale_grid,
},
textEditor: {
textWrapping: configuration?.settings?.text_editor?.text_wrapping,
blinkingCursor: configuration?.settings?.text_editor?.blinking_cursor,
},
projects: {
defaultProjectName:
configuration?.settings?.project?.default_project_name,
},
commandBar: {
includeSettings: configuration?.settings?.command_bar?.include_settings,
},
}
}
export function projectConfigurationToSettingsPayload(
configuration: DeepPartial<ProjectConfiguration>
): DeepPartial<SaveSettingsPayload> {
return {
app: {
// do not read in `theme`, because it is blocked on the project level
themeColor: configuration?.settings?.app?.appearance?.color
? configuration?.settings?.app?.appearance?.color.toString()
: undefined,
onboardingStatus: configuration?.settings?.app?.onboarding_status,
dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner,
streamIdleMode: configuration?.settings?.app?.stream_idle_mode,
enableSSAO: configuration?.settings?.modeling?.enable_ssao,
},
modeling: {
defaultUnit: configuration?.settings?.modeling?.base_unit,
mouseControls: mouseControlsToCameraSystem(
configuration?.settings?.modeling?.mouse_controls
),
highlightEdges: configuration?.settings?.modeling?.highlight_edges,
showDebugPanel: configuration?.settings?.modeling?.show_debug_panel,
},
textEditor: {
textWrapping: configuration?.settings?.text_editor?.text_wrapping,
blinkingCursor: configuration?.settings?.text_editor?.blinking_cursor,
},
commandBar: {
includeSettings: configuration?.settings?.command_bar?.include_settings,
},
}
}
function localStorageAppSettingsPath() {
return '/settings.toml'
}
function localStorageProjectSettingsPath() {
return '/' + BROWSER_PROJECT_NAME + '/project.toml'
}
export function readLocalStorageAppSettingsFile():
| DeepPartial<Configuration>
| Error {
// TODO: Remove backwards compatibility after a few releases.
let stored =
localStorage.getItem(localStorageAppSettingsPath()) ??
localStorage.getItem('/user.toml') ??
''
if (stored === '') {
return defaultAppSettings()
}
try {
return parseAppSettings(stored)
} catch (e) {
const settings = defaultAppSettings()
if (err(settings)) return settings
const tomlStr = tomlStringify(settings)
if (err(tomlStr)) return tomlStr
localStorage.setItem(localStorageAppSettingsPath(), tomlStr)
return settings
}
}
function readLocalStorageProjectSettingsFile():
| DeepPartial<ProjectConfiguration>
| Error {
// TODO: Remove backwards compatibility after a few releases.
let stored = localStorage.getItem(localStorageProjectSettingsPath()) ?? ''
if (stored === '') {
return defaultProjectSettings()
}
const projectSettings = parseProjectSettings(stored)
if (err(projectSettings)) {
const settings = defaultProjectSettings()
const tomlStr = tomlStringify(settings)
if (err(tomlStr)) return tomlStr
localStorage.setItem(localStorageProjectSettingsPath(), tomlStr)
return settings
} else {
return projectSettings
}
}
export interface AppSettings {
settings: ReturnType<typeof createSettings>
configuration: DeepPartial<Configuration>
}
export async function loadAndValidateSettings(
projectPath?: string
): Promise<AppSettings> {
// Make sure we have wasm initialized.
await initPromise
const onDesktop = isDesktop()
// Load the app settings from the file system or localStorage.
const appSettingsPayload = onDesktop
? await readAppSettingsFile()
: readLocalStorageAppSettingsFile()
if (err(appSettingsPayload)) return Promise.reject(appSettingsPayload)
let settingsNext = createSettings()
// Because getting the default directory is async, we need to set it after
if (onDesktop) {
settings.app.projectDirectory.default = await getInitialDefaultDir()
}
settingsNext = setSettingsAtLevel(
settingsNext,
'user',
configurationToSettingsPayload(appSettingsPayload)
)
// Load the project settings if they exist
if (projectPath) {
const projectSettings = onDesktop
? await readProjectSettingsFile(projectPath)
: readLocalStorageProjectSettingsFile()
if (err(projectSettings))
return Promise.reject(new Error('Invalid project settings'))
const projectSettingsPayload = projectSettings
settingsNext = setSettingsAtLevel(
settingsNext,
'project',
projectConfigurationToSettingsPayload(projectSettingsPayload)
)
}
// Return the settings object
return {
settings: settingsNext,
configuration: appSettingsPayload,
}
}
export async function saveSettings(
allSettings: typeof settings,
projectPath?: string
) {
// Make sure we have wasm initialized.
await initPromise
const onDesktop = isDesktop()
// Get the user settings.
const jsAppSettings = getChangedSettingsAtLevel(allSettings, 'user')
const appTomlString = tomlStringify({ settings: jsAppSettings })
if (err(appTomlString)) return
// Write the app settings.
if (onDesktop) {
await writeAppSettingsFile(appTomlString)
} else {
localStorage.setItem(localStorageAppSettingsPath(), appTomlString)
}
if (!projectPath) {
// If we're not saving project settings, we're done.
return
}
// Get the project settings.
const jsProjectSettings = getChangedSettingsAtLevel(allSettings, 'project')
const projectTomlString = tomlStringify({ settings: jsProjectSettings })
if (err(projectTomlString)) return
// Write the project settings.
if (onDesktop) {
await writeProjectSettingsFile(projectPath, projectTomlString)
} else {
localStorage.setItem(localStorageProjectSettingsPath(), projectTomlString)
}
}
export function getChangedSettingsAtLevel(
allSettings: typeof settings,
level: SettingsLevel
): Partial<SaveSettingsPayload> {
const changedSettings = {} as Record<
keyof typeof settings,
Record<string, unknown>
>
Object.entries(allSettings).forEach(([category, settingsCategory]) => {
const categoryKey = category as keyof typeof settings
Object.entries(settingsCategory).forEach(
([setting, settingValue]: [string, Setting]) => {
// If setting is different its ancestors' non-undefined values,
// then it has been changed from the default
if (
settingValue[level] !== undefined &&
((level === 'project' &&
(settingValue.user !== undefined
? settingValue.project !== settingValue.user
: settingValue.project !== settingValue.default)) ||
(level === 'user' && settingValue.user !== settingValue.default))
) {
if (!changedSettings[categoryKey]) {
changedSettings[categoryKey] = {}
}
changedSettings[categoryKey][setting] = settingValue[level]
}
}
)
})
return changedSettings
}
export function setSettingsAtLevel(
allSettings: typeof settings,
level: SettingsLevel,
newSettings: Partial<SaveSettingsPayload>
) {
Object.entries(newSettings).forEach(([category, settingsCategory]) => {
const categoryKey = category as keyof typeof settings
if (!allSettings[categoryKey]) return // ignore unrecognized categories
Object.entries(settingsCategory).forEach(([settingKey, settingValue]) => {
// TODO: How do you get a valid type for allSettings[categoryKey][settingKey]?
// it seems to always collapses to `never`, which is not correct
// @ts-ignore
if (!allSettings[categoryKey][settingKey]) return // ignore unrecognized settings
// @ts-ignore
allSettings[categoryKey][settingKey][level] = settingValue as unknown
})
})
return allSettings
}
/**
* Returns true if the setting should be hidden
* based on its config, the current settings level,
* and the current platform.
*/
export function shouldHideSetting(
setting: Setting<unknown>,
settingsLevel: SettingsLevel
) {
return (
setting.hideOnLevel === settingsLevel ||
setting.hideOnPlatform === 'both' ||
(setting.hideOnPlatform && isDesktop()
? setting.hideOnPlatform === 'desktop'
: setting.hideOnPlatform === 'web')
)
}
/**
* Returns true if the setting meets the requirements
* to appear in the settings modal in this context
* based on its config, the current settings level,
* and the current platform
*/
export function shouldShowSettingInput(
setting: Setting<unknown>,
settingsLevel: SettingsLevel
) {
return (
!shouldHideSetting(setting, settingsLevel) &&
(setting.Component ||
['string', 'boolean'].some((t) => typeof setting.default === t) ||
(setting.commandConfig?.inputType &&
['string', 'options', 'boolean'].some(
(t) => setting.commandConfig?.inputType === t
)))
)
}
/**
* Get the appropriate input type to show given a
* command's config. Highly dependent on the filtering logic from
* shouldShowSettingInput being applied
*/
export function getSettingInputType(setting: Setting) {
if (setting.Component) return 'component'
if (setting.commandConfig)
return setting.commandConfig.inputType as 'string' | 'options' | 'boolean'
return typeof setting.default as 'string' | 'boolean'
}