Files
modeling-app/src/machines/settingsMachine.ts
Kurt Hutten 60bc38559b fix sketch mode scale issue (#6107)
* fix sketch mode scale issue

* fmt

* Fix snapshot tests in kurt-5621-scale-issue (#6111)

* Change test function to share units with settings (#6114)

* Change test function to share units with settings

* Consolidate to the same module

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
2025-04-02 16:31:18 -04:00

584 lines
18 KiB
TypeScript

import decamelize from 'decamelize'
import toast from 'react-hot-toast'
import type { AnyActorRef } from 'xstate'
import {
assign,
enqueueActions,
fromCallback,
fromPromise,
sendTo,
setup,
} from 'xstate'
import type { NamedView } from '@rust/kcl-lib/bindings/NamedView'
import {
createSettingsCommand,
settingsWithCommandConfigs,
} from '@src/lib/commandBarConfigs/settingsCommandConfig'
import type { Command } from '@src/lib/commandTypes'
import type { Project } from '@src/lib/project'
import type { SettingsType } from '@src/lib/settings/initialSettings'
import { createSettings, settings } from '@src/lib/settings/initialSettings'
import type {
BaseUnit,
SetEventTypes,
SettingsLevel,
SettingsPaths,
WildcardSetEvent,
} from '@src/lib/settings/settingsTypes'
import {
clearSettingsAtLevel,
configurationToSettingsPayload,
loadAndValidateSettings,
projectConfigurationToSettingsPayload,
saveSettings,
setSettingsAtLevel,
} from '@src/lib/settings/settingsUtils'
import {
codeManager,
engineCommandManager,
kclManager,
sceneEntitiesManager,
sceneInfra,
} from '@src/lib/singletons'
import {
Themes,
darkModeMatcher,
getOppositeTheme,
getSystemTheme,
setThemeClass,
} from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { commandBarActor } from '@src/machines/commandBarMachine'
type SettingsMachineContext = SettingsType & {
currentProject?: Project
}
export const settingsMachine = setup({
types: {
context: {} as SettingsMachineContext,
input: {} as SettingsMachineContext,
events: {} as (
| WildcardSetEvent<SettingsPaths>
| SetEventTypes
| {
type: 'set.app.theme'
data: { level: SettingsLevel; value: Themes }
}
| {
type: 'set.modeling.units'
data: { level: SettingsLevel; value: BaseUnit }
}
| {
type: 'Reset settings'
level: SettingsLevel
}
| { type: 'Set all settings'; settings: typeof settings }
| { type: 'set.app.namedViews'; value: NamedView }
| { type: 'load.project'; project?: Project }
| { type: 'clear.project' }
) & { doNotPersist?: boolean },
},
actors: {
persistSettings: fromPromise<
void,
{ doNotPersist: boolean; context: SettingsMachineContext }
>(async ({ input }) => {
// Without this, when a user changes the file, it'd
// create a detection loop with the file-system watcher.
if (input.doNotPersist) return
codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
const { currentProject, ...settings } = input.context
return saveSettings(settings, currentProject?.path)
}),
loadUserSettings: fromPromise<SettingsMachineContext, void>(async () => {
const { settings } = await loadAndValidateSettings()
return settings
}),
loadProjectSettings: fromPromise<
SettingsMachineContext,
{ project?: Project }
>(async ({ input }) => {
const { settings } = await loadAndValidateSettings(input.project?.path)
return settings
}),
watchSystemTheme: fromCallback<{
type: 'update.themeWatcher'
theme: Themes
}>(({ receive }) => {
const listener = (e: MediaQueryListEvent) => {
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
}
receive((event) => {
if (event.type !== 'update.themeWatcher') {
return
} else {
if (event.theme === Themes.System) {
darkModeMatcher?.addEventListener('change', listener)
} else {
darkModeMatcher?.removeEventListener('change', listener)
}
}
})
return () => darkModeMatcher?.removeEventListener('change', listener)
}),
registerCommands: fromCallback<
{ type: 'update' },
{ settings: SettingsType; actor: AnyActorRef }
>(({ input, receive }) => {
// If the user wants to hide the settings commands
//from the command bar don't add them.
if (settings.commandBar.includeSettings.current === false) return
let commands: Command[] = []
const updateCommands = () =>
settingsWithCommandConfigs(input.settings)
.map((type) =>
createSettingsCommand({
type,
actor: input.actor,
})
)
.filter((c) => c !== null)
const addCommands = () =>
commandBarActor.send({
type: 'Add commands',
data: { commands: commands },
})
const removeCommands = () =>
commandBarActor.send({
type: 'Remove commands',
data: { commands: commands },
})
receive((event) => {
if (event.type !== 'update') return
removeCommands()
commands = updateCommands()
addCommands()
})
commands = updateCommands()
addCommands()
return () => {
removeCommands()
}
}),
},
actions: {
setEngineTheme: ({ context }) => {
if (engineCommandManager && context.app.theme.current) {
engineCommandManager
.setTheme(context.app.theme.current)
.catch(reportRejection)
}
},
setClientTheme: ({ context }) => {
if (!sceneInfra || !sceneEntitiesManager) return
const opposingTheme = getOppositeTheme(context.app.theme.current)
sceneInfra.theme = opposingTheme
sceneEntitiesManager.updateSegmentBaseColor(opposingTheme)
},
setAllowOrbitInSketchMode: ({ context }) => {
if (!sceneInfra.camControls) return
sceneInfra.camControls._setting_allowOrbitInSketchMode =
context.app.allowOrbitInSketchMode.current
// ModelingMachineProvider will do a use effect to trigger the camera engine sync
},
toastSuccess: ({ event }) => {
if (!('data' in event)) return
const eventParts = event.type.replace(/^set./, '').split('.') as [
keyof typeof settings,
string,
]
const truncatedNewValue = event.data.value?.toString().slice(0, 28)
const message =
`Set ${decamelize(eventParts[1], { separator: ' ' })}` +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"${
event.data.level === 'project'
? ' for this project'
: ' as a user default'
}`
: '')
toast.success(message, {
duration: message.split(' ').length * 100 + 1500,
id: `${event.type}.success`,
})
},
'Execute AST': ({ context, event }) => {
try {
const relevantSetting = (s: typeof settings) => {
return (
s.modeling?.defaultUnit?.current !==
context.modeling.defaultUnit.current ||
s.modeling.showScaleGrid.current !==
context.modeling.showScaleGrid.current ||
s.modeling?.highlightEdges.current !==
context.modeling.highlightEdges.current
)
}
const allSettingsIncludesUnitChange =
event.type === 'Set all settings' &&
relevantSetting(event.settings || context)
const resetSettingsIncludesUnitChange =
event.type === 'Reset settings' && relevantSetting(settings)
const shouldExecute =
kclManager !== undefined &&
(event.type === 'set.modeling.defaultUnit' ||
event.type === 'set.modeling.showScaleGrid' ||
event.type === 'set.modeling.highlightEdges' ||
allSettingsIncludesUnitChange ||
resetSettingsIncludesUnitChange)
if (shouldExecute) {
// Unit changes requires a re-exec of code
kclManager.executeCode(true).catch(reportRejection)
} else {
// For any future logging we'd like to do
// console.log(
// 'Not re-executing AST because the settings change did not affect the code interpretation'
// )
}
} catch (e) {
console.error('Error executing AST after settings change', e)
}
},
setThemeColor: ({ context }) => {
document.documentElement.style.setProperty(
`--primary-hue`,
context.app.themeColor.current
)
},
/**
* Update the --cursor-color CSS variable
* based on the setting textEditor.blinkingCursor.current
*/
setCursorColor: ({ context }) => {
document.documentElement.style.setProperty(
`--cursor-color`,
context.textEditor.blinkingCursor.current ? 'auto' : 'transparent'
)
},
/** Unload the project-level setting values from memory */
clearProjectSettings: assign(({ context }) => {
// Peel off all non-settings context
const { currentProject: _, ...settings } = context
const newSettings = clearSettingsAtLevel(settings, 'project')
return newSettings
}),
/** Unload the current project's info from memory */
clearCurrentProject: assign(({ context }) => {
return { ...context, currentProject: undefined }
}),
resetSettings: assign(({ context, event }) => {
if (!('level' in event)) return {}
// Create a new, blank payload
const newPayload =
event.level === 'user'
? configurationToSettingsPayload({})
: projectConfigurationToSettingsPayload({})
// Reset the settings at that level
const newSettings = setSettingsAtLevel(context, event.level, newPayload)
return newSettings
}),
setAllSettings: assign(({ event, context }) => {
if ('settings' in event) return event.settings
else if ('output' in event) return event.output || context
else return context
}),
setSettingAtLevel: assign(({ context, event }) => {
if (!('data' in event)) return {}
const { level, value } = event.data
const [category, setting] = event.type
.replace(/^set./, '')
.split('.') as [keyof typeof settings, string]
// @ts-ignore
context[category][setting][level] = value
const newContext = {
...context,
[category]: {
...context[category],
// @ts-ignore
[setting]: context[category][setting],
},
}
return newContext
}),
setThemeClass: ({ context }) => {
const currentTheme = context.app.theme.current ?? Themes.System
setThemeClass(
currentTheme === Themes.System ? getSystemTheme() : currentTheme
)
},
setEngineCameraProjection: ({ context }) => {
const newCurrentProjection = context.modeling.cameraProjection.current
sceneInfra.camControls.setEngineCameraProjection(newCurrentProjection)
},
sendThemeToWatcher: sendTo('watchSystemTheme', ({ context }) => ({
type: 'update.themeWatcher',
theme: context.app.theme.current,
})),
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IAzAA4x+AIyaAbJoCsAFl1njAJmOaANCACeiXQHZ1+a7bdWDATnUxawBfYIdUDB4CIlIKKjoGNAALMABbMABhRmJGDnEpJBBZeUVsZTUELR19IzMLUy97J0RfTXxDBr8DAxtdMSs-UPD0LFxoknJKNHxUxggwYh58eYAzakFiNABVbAV85WKFTCVCiqq9QxNzSxsm50qDU3wrMXV1V2M-bT8xV6GQCKjPCECZxaYJfDJNJgfaFQ6lcoabQXWrXBq3Bz3YzqPz4AyvL7qYw1TS6Az-QFREGxKY0ej4WBoDhgaipACSEwAsnMYZIDnIjidQGdkSS6jdbJjEK5zHi-PouqYiQZjBSRlSYpN4vTqMQcgB3ADyHBYCjZ2GQAGt0ABjJLc+awmQChGnJHVS7i9GS5oIQweVxueVWVz4mW6NWRMbUrXTWbzRa4fA21lgDjUAAKHEYACswDbSk6ii7jmU3ZVRZ60Y0pQg+rorPglQ2rKZ-FY3DLI0DxjSqPGFkskpgoElFqO0ABRaBwIvw0uIise1H1Gu+3Rk3R6Uydaz47qqsIA9XRzVkABKcHQAAIpj25yWhap3SirquMevAriTK4urYBhYViaN2GqghE166sQt4nngD4lAu5Zkni1yaKG2i6OoBgblYtY7sYniGNYmjqKYmjyropggaeoK0gOiZQAySSMPqyApqQADiHBEHBgplsKL5itWH73BYKr4OoLzmBhHahlRwJnjk1AQPgtDZnmBY8a6-EIKYVgePopEfKRmhAQ2xi1m8FyuCGnxmHhJFyb25AAFSaQh2nnIJ74+iJgZPK87ykmR-SmK4wFHpS0a0Gm8iMjw0FRngZAQMwYCENgABujDWvgkXAtFHCxUCCU9ggOBZSmhaSG5T4VKFuIkUEO7uPKfihaYtb6JZQR+J8Sq-iR4XDIlBAFUV8V3lEZBptmHAqcQAgrLkqS5TBo0xZgcW4CVURlZljCVaW+Q1Xxz46ciRhiFhBjvEEPmIKSVk2b1O4NA5EVrfgincLgWyUBwyWpelWU5XlBDfTwf1pntFUCEd1V8nCj6nWczyfPiYgfL4xhhZoqGdR8OhXT0xJiL1HxaI5X3sD9UBQwDM25PNi3LatI3U0pkP-TDB1w8wx2I868G1RomEEURGHWZjrydV0Hjym2bw3bY2LqFTEO4Fmub5mggPYGl5XZWlYMc7TWvqWgPOHfzCMFELvGLn035dGIPgYW1ui9bLv7PK8LxGGRFG6erNM8ObOvTRws3M2gS0cCtJsa1A4cFlbfPYALdvFsLKMaMS+BiKYfg2NigZk+85m+h2pg6L+MpthhKtEqER7YDy8CFGD-I54uAC06ie88ZL4i8FFfmSta92YBe-L8fhhaGLyYVTmrdw75ahbWqEGPgfjyj+PR762EYfezY2bcVk1jGvWlnWRTxB8YWEysY6O6Fve94vUB5fDXxIh5zX6-0b7uTOr3EMW4OzdH6K7JUZMJ7rh3I2X+PRpZvE+K4ABZs1I6xASLBAYhawdj6M8CSG4F64zVi3IAA */
initial: 'loadingUser',
context: ({ input }) => {
return {
...createSettings(),
...input,
}
},
invoke: [
{
src: 'watchSystemTheme',
id: 'watchSystemTheme',
},
{
src: 'registerCommands',
id: 'registerCommands',
// Peel off the non-settings context
input: ({ context: { currentProject, ...settings }, self }) => ({
settings,
actor: self,
}),
},
],
states: {
idle: {
entry: ['setThemeClass', 'sendThemeToWatcher'],
on: {
'*': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
enqueueActions(({ enqueue, check }) => {
if (
check(
({ event }) => event.type === 'set.textEditor.blinkingCursor'
)
) {
enqueue('setCursorColor')
}
}),
],
},
'set.app.namedViews': {
target: 'persisting settings',
actions: ['setSettingAtLevel'],
},
'set.app.onboardingStatus': {
target: 'persisting settings',
// No toast
actions: ['setSettingAtLevel'],
},
'set.app.themeColor': {
target: 'persisting settings',
// No toast
actions: ['setSettingAtLevel', 'setThemeColor'],
},
'set.modeling.defaultUnit': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'],
},
'set.app.theme': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setThemeClass',
'setEngineTheme',
'setClientTheme',
'sendThemeToWatcher',
],
},
'set.app.streamIdleMode': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess'],
},
'set.app.allowOrbitInSketchMode': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setAllowOrbitInSketchMode',
],
},
'set.modeling.cameraProjection': {
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setEngineCameraProjection',
],
},
'set.modeling.highlightEdges': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'],
},
'Reset settings': {
target: 'persisting settings',
actions: [
'resetSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'Execute AST',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
],
},
'Set all settings': {
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'Execute AST',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
],
},
'set.modeling.showScaleGrid': {
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'Execute AST'],
},
'load.project': {
target: 'loadingProject',
},
'clear.project': {
target: 'idle',
reenter: true,
actions: [
'clearProjectSettings',
'clearCurrentProject',
'setThemeColor',
sendTo('registerCommands', { type: 'update' }),
],
},
},
},
'persisting settings': {
invoke: {
src: 'persistSettings',
onDone: {
target: 'idle',
},
onError: {
target: 'idle',
actions: () => {
console.error('Error persisting settings')
},
},
input: ({ context, event }) => {
return {
doNotPersist: event.doNotPersist ?? false,
context,
}
},
},
},
loadingUser: {
invoke: {
src: 'loadUserSettings',
onDone: {
target: 'idle',
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
],
},
onError: {
target: 'idle',
actions: ({ event }) => {
console.error('Error loading user settings', event)
},
},
},
},
loadingProject: {
entry: [
assign({
currentProject: ({ event }) =>
event.type === 'load.project' ? event.project : undefined,
}),
],
invoke: {
src: 'loadProjectSettings',
onDone: {
target: 'idle',
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setThemeColor',
'Execute AST',
'setClientTheme',
'setAllowOrbitInSketchMode',
'sendThemeToWatcher',
sendTo('registerCommands', { type: 'update' }),
],
},
onError: 'idle',
input: ({ event }) => {
return {
project: event.type === 'load.project' ? event.project : undefined,
}
},
},
},
},
})