import { DEFAULT_PROJECT_NAME } from 'lib/constants' import { BaseUnit, SettingProps, SettingsLevel, baseUnitsUnion, } from 'lib/settings/settingsTypes' import { Themes } from 'lib/theme' import { isEnumMember } from 'lib/types' import { CameraSystem, cameraMouseDragGuards, cameraSystems, } from 'lib/cameraControls' import { isDesktop } from 'lib/isDesktop' import { useRef } from 'react' import { CustomIcon } from 'components/CustomIcon' import Tooltip from 'components/Tooltip' import { toSync } from 'lib/utils' import { reportRejection } from 'lib/trap' import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType' /** * A setting that can be set at the user or project level * @constructor */ export class Setting { /** * The current value of the setting, prioritizing project, then user, then default */ public current: T public hideOnLevel: SettingProps['hideOnLevel'] public hideOnPlatform: SettingProps['hideOnPlatform'] public commandConfig: SettingProps['commandConfig'] public Component: SettingProps['Component'] public description?: string private validate: (v: T) => boolean private _default: T private _user?: T private _project?: T constructor(props: SettingProps) { this._default = props.defaultValue this.current = props.defaultValue this.validate = props.validate this.description = props.description this.hideOnLevel = props.hideOnLevel this.hideOnPlatform = props.hideOnPlatform this.commandConfig = props.commandConfig this.Component = props.Component } /** * The default setting. Overridden by the user and project if set */ get default(): T { return this._default } set default(v: T) { this._default = this.validate(v) ? v : this._default this.current = this.resolve() } /** * The user-level setting. Overrides the default, overridden by the project */ get user(): T | undefined { return this._user } set user(v: T | undefined) { this._user = v !== undefined ? (this.validate(v) ? v : this._user) : v this.current = this.resolve() } /** * The project-level setting. Overrides the user and default */ get project(): T | undefined { return this._project } set project(v: T | undefined) { this._project = v !== undefined ? (this.validate(v) ? v : this._project) : v this.current = this.resolve() } /** * @returns {T} - The value of the setting, prioritizing project, then user, then default * @todo - This may have issues if future settings can have a value that is valid but falsy */ private resolve() { return this._project !== undefined ? this._project : this._user !== undefined ? this._user : this._default } /** * @param {SettingsLevel} level - The level to get the fallback for * @returns {T} - The value of the setting above the given level, falling back as needed */ public getFallback(level: SettingsLevel | 'default'): T { return level === 'project' ? this._user !== undefined ? this._user : this._default : this._default } /** * For the purposes of showing the `current` label in the command bar, * is this setting at the given level the same as the given value? */ public shouldShowCurrentLabel( level: SettingsLevel | 'default', valueToMatch: T ): boolean { return this[`_${level}`] === undefined ? this.getFallback(level) === valueToMatch : this[`_${level}`] === valueToMatch } public getParentLevel(level: SettingsLevel): SettingsLevel | 'default' { return level === 'project' ? 'user' : 'default' } } export function createSettings() { return { /** Settings that affect the behavior of the entire app, * beyond just modeling or navigating, for example */ app: { /** * The overall appearance of the app: light, dark, or system */ theme: new Setting({ hideOnLevel: 'project', defaultValue: Themes.System, description: 'The overall appearance of the app', validate: (v) => isEnumMember(v, Themes), commandConfig: { inputType: 'options', defaultValueFromContext: (context) => context.app.theme.current, options: (cmdContext, settingsContext) => Object.values(Themes).map((v) => ({ name: v, value: v, isCurrent: v === settingsContext.app.theme[ cmdContext.argumentsToSubmit.level as SettingsLevel ], })), }, }), themeColor: new Setting({ defaultValue: '264.5', description: 'The hue of the primary theme color for the app', validate: (v) => Number(v) >= 0 && Number(v) < 360, Component: ({ value, updateValue }) => (
updateValue(e.currentTarget.value)} value={value} min={0} max={259} step={1} className="block flex-1" />
), }), enableSSAO: new Setting({ defaultValue: true, description: 'Whether or not Screen Space Ambient Occlusion (SSAO) is enabled', validate: (v) => typeof v === 'boolean', hideOnPlatform: 'both', //for now }), /** * Stream resource saving behavior toggle */ streamIdleMode: new Setting({ defaultValue: false, description: 'Toggle stream idling, saving bandwidth and battery', validate: (v) => typeof v === 'boolean', commandConfig: { inputType: 'boolean', }, }), onboardingStatus: new Setting({ defaultValue: '', validate: (v) => typeof v === 'string', hideOnPlatform: 'both', }), /** Permanently dismiss the banner warning to download the desktop app. */ dismissWebBanner: new Setting({ defaultValue: false, description: 'Permanently dismiss the banner warning to download the desktop app.', validate: (v) => typeof v === 'boolean', hideOnPlatform: 'desktop', }), projectDirectory: new Setting({ defaultValue: '', description: 'The directory to save and load projects from', hideOnLevel: 'project', hideOnPlatform: 'web', validate: (v) => typeof v === 'string' && (v.length > 0 || !isDesktop()), Component: ({ value, updateValue }) => { const inputRef = useRef(null) return (
) }, }), }, /** * Settings that affect the behavior while modeling. */ modeling: { /** * The default unit to use in modeling dimensions */ defaultUnit: new Setting({ defaultValue: 'mm', description: 'The default unit to use in modeling dimensions', validate: (v) => baseUnitsUnion.includes(v as BaseUnit), commandConfig: { inputType: 'options', defaultValueFromContext: (context) => context.modeling.defaultUnit.current, options: (cmdContext, settingsContext) => Object.values(baseUnitsUnion).map((v) => ({ name: v, value: v, isCurrent: v === settingsContext.modeling.defaultUnit[ cmdContext.argumentsToSubmit.level as SettingsLevel ], })), }, }), /** * The controls for how to navigate the 3D view */ mouseControls: new Setting({ defaultValue: 'Zoo', description: 'The controls for how to navigate the 3D view', validate: (v) => cameraSystems.includes(v as CameraSystem), hideOnLevel: 'project', commandConfig: { inputType: 'options', defaultValueFromContext: (context) => context.modeling.mouseControls.current, options: (cmdContext, settingsContext) => Object.values(cameraSystems).map((v) => ({ name: v, value: v, isCurrent: v === settingsContext.modeling.mouseControls.shouldShowCurrentLabel( cmdContext.argumentsToSubmit.level as SettingsLevel ), })), }, Component: ({ value, updateValue }) => ( <>
  • Pan

    {cameraMouseDragGuards[value].pan.description}

  • Zoom

    {cameraMouseDragGuards[value].zoom.description}

  • Rotate

    {cameraMouseDragGuards[value].rotate.description}

), }), /** * Projection method applied to the 3D view, perspective or orthographic */ cameraProjection: new Setting({ defaultValue: 'orthographic', hideOnLevel: 'project', description: 'Projection method applied to the 3D view, perspective or orthographic', validate: (v) => ['perspective', 'orthographic'].includes(v), commandConfig: { inputType: 'options', // This is how we could have toggling behavior for a non-boolean argument: // Set it to "skippable", and make the default value the opposite of the current value // skip: true, defaultValueFromContext: (context) => context.modeling.cameraProjection.current === 'perspective' ? 'orthographic' : 'perspective', options: (cmdContext, settingsContext) => (['perspective', 'orthographic'] as const).map((v) => ({ name: v.charAt(0).toUpperCase() + v.slice(1), value: v, isCurrent: settingsContext.modeling.cameraProjection.shouldShowCurrentLabel( cmdContext.argumentsToSubmit.level as SettingsLevel, v ), })), }, }), /** * Whether to highlight edges of 3D objects */ highlightEdges: new Setting({ defaultValue: true, description: 'Whether to highlight edges of 3D objects', validate: (v) => typeof v === 'boolean', commandConfig: { inputType: 'boolean', }, hideOnLevel: 'project', }), /** * Whether to show a scale grid in the 3D modeling view */ showScaleGrid: new Setting({ defaultValue: false, description: 'Whether to show a scale grid in the 3D modeling view', validate: (v) => typeof v === 'boolean', commandConfig: { inputType: 'boolean', }, hideOnLevel: 'project', }), /** * Whether to show the debug panel, which lets you see * various states of the app to aid in development */ showDebugPanel: new Setting({ defaultValue: false, description: 'Whether to show the debug panel, a development tool', validate: (v) => typeof v === 'boolean', commandConfig: { inputType: 'boolean', }, }), /** * TODO: This setting is not yet implemented. * Whether to turn off animations and other motion effects */ // reduceMotion: new Setting({ // defaultValue: false, // description: 'Whether to turn off animations and other motion effects', // validate: (v) => typeof v === 'boolean', // commandConfig: { // inputType: 'boolean', // }, // hideOnLevel: 'project', // }), /** * TODO: This setting is not yet implemented. * Whether to move to view the sketch plane orthogonally * when creating entering or creating a sketch. */ // moveOrthoginalToSketch: new Setting({ // defaultValue: false, // description: 'Whether to move to view sketch planes orthogonally', // validate: (v) => typeof v === 'boolean', // commandConfig: { // inputType: 'boolean', // }, // }), }, /** * Settings that affect the behavior of the KCL text editor. */ textEditor: { /** * Whether to wrap text in the editor or overflow with scroll */ textWrapping: new Setting({ defaultValue: true, description: 'Whether to wrap text in the editor or overflow with scroll', validate: (v) => typeof v === 'boolean', commandConfig: { inputType: 'boolean', }, }), /** * Whether to make the cursor blink in the editor */ blinkingCursor: new Setting({ defaultValue: true, description: 'Whether to make the cursor blink in the editor', validate: (v) => typeof v === 'boolean', commandConfig: { inputType: 'boolean', }, }), }, /** * Settings that affect the behavior of project management. */ projects: { /** * The default project name to use when creating a new project */ defaultProjectName: new Setting({ defaultValue: DEFAULT_PROJECT_NAME, description: 'The default project name to use when creating a new project', validate: (v) => typeof v === 'string' && v.length > 0, commandConfig: { inputType: 'string', defaultValueFromContext: (context) => context.projects.defaultProjectName.current, }, hideOnLevel: 'project', hideOnPlatform: 'web', }), /** * TODO: This setting is not yet implemented. * It requires more sophisticated fallback logic if the user sets this setting to a * non-existent file. This setting is currently hardcoded to PROJECT_ENTRYPOINT. * The default file to open when a project is loaded */ // entryPointFileName: new Setting({ // defaultValue: PROJECT_ENTRYPOINT, // description: 'The default file to open when a project is loaded', // validate: (v) => typeof v === 'string' && v.length > 0, // commandConfig: { // inputType: 'string', // defaultValueFromContext: (context) => // context.projects.entryPointFileName.current, // }, // hideOnLevel: 'project', // }), }, /** * Settings that affect the behavior of the command bar. */ commandBar: { /** * Whether to include settings in the command bar */ includeSettings: new Setting({ defaultValue: true, description: 'Whether to include settings in the command bar', validate: (v) => typeof v === 'boolean', commandConfig: { inputType: 'boolean', }, }), }, } } export const settings = createSettings()