Update lower-right corner units menu to read and edit inline settings annotations if present (#5212)

* WIP show annotation length unit setting in LowerRightControls if present

* Add logic for changing settings annotation if it's present

* Add E2E test

* Cleanup lints, fmt, tsc, logs

* Change to use settings from Rust helper function

- Fix thrown error to use the cause field
- Fix function names to not use "get"

* Remove unneeded constants

* Post-merge fixups

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Add back `ImportStatement` to make tsc happy (thanks @jtran!)

* fmt

---------

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2025-02-06 12:37:13 -05:00
committed by GitHub
parent 019cb815f9
commit 1c0a38a1e2
9 changed files with 262 additions and 15 deletions

View File

@ -896,4 +896,53 @@ test.describe('Testing settings', () => {
}) })
} }
) )
test(`Change inline units setting`, async ({
page,
homePage,
context,
editor,
}) => {
const initialInlineUnits = 'yd'
const editedInlineUnits = { short: 'mm', long: 'Millimeters' }
const inlineSettingsString = (s: string) =>
`@settings(defaultLengthUnit = ${s})`
const unitsIndicator = page.getByRole('button', {
name: 'Current units are:',
})
const unitsChangeButton = (name: string) =>
page.getByRole('button', { name, exact: true })
await context.folderSetupFn(async (dir) => {
const bracketDir = join(dir, 'project-000')
await fsp.mkdir(bracketDir, { recursive: true })
await fsp.copyFile(
executorInputPath('cube.kcl'),
join(bracketDir, 'main.kcl')
)
})
await test.step(`Initial units from settings`, async () => {
await homePage.openProject('project-000')
await expect(unitsIndicator).toHaveText('Current units are: in')
})
await test.step(`Manually write inline settings`, async () => {
await editor.openPane()
await editor.replaceCode(
`fn cube`,
`${inlineSettingsString(initialInlineUnits)}
fn cube`
)
await expect(unitsIndicator).toContainText(initialInlineUnits)
})
await test.step(`Change units setting via lower-right control`, async () => {
await unitsIndicator.click()
await unitsChangeButton(editedInlineUnits.long).click()
await expect(
page.getByText(`Updated per-file units to ${editedInlineUnits.short}`)
).toBeVisible()
})
})
}) })

View File

@ -1,9 +1,31 @@
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { changeKclSettings, unitLengthToUnitLen } from 'lang/wasm'
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes' import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
import { codeManager, kclManager } from 'lib/singletons'
import { err, reportRejection } from 'lib/trap'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
export function UnitsMenu() { export function UnitsMenu() {
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const [hasPerFileLengthUnit, setHasPerFileLengthUnit] = useState(
Boolean(kclManager.fileSettings.defaultLengthUnit)
)
const [lengthSetting, setLengthSetting] = useState(
kclManager.fileSettings.defaultLengthUnit ||
settings.context.modeling.defaultUnit.current
)
useEffect(() => {
setHasPerFileLengthUnit(Boolean(kclManager.fileSettings.defaultLengthUnit))
setLengthSetting(
kclManager.fileSettings.defaultLengthUnit ||
settings.context.modeling.defaultUnit.current
)
}, [
kclManager.fileSettings.defaultLengthUnit,
settings.context.modeling.defaultUnit.current,
])
return ( return (
<Popover className="relative pointer-events-auto"> <Popover className="relative pointer-events-auto">
{({ close }) => ( {({ close }) => (
@ -18,7 +40,7 @@ export function UnitsMenu() {
<div className="absolute w-[1px] h-[1em] bg-primary right-0 top-1/2 -translate-y-1/2"></div> <div className="absolute w-[1px] h-[1em] bg-primary right-0 top-1/2 -translate-y-1/2"></div>
</div> </div>
<span className="sr-only">Current units are:&nbsp;</span> <span className="sr-only">Current units are:&nbsp;</span>
{settings.context.modeling.defaultUnit.current} {lengthSetting}
</Popover.Button> </Popover.Button>
<Popover.Panel <Popover.Panel
className={`absolute bottom-full right-0 mb-2 w-48 bg-chalkboard-10 dark:bg-chalkboard-90 className={`absolute bottom-full right-0 mb-2 w-48 bg-chalkboard-10 dark:bg-chalkboard-90
@ -31,6 +53,27 @@ export function UnitsMenu() {
<button <button
className="flex items-center gap-2 m-0 py-1.5 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left" className="flex items-center gap-2 m-0 py-1.5 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
onClick={() => { onClick={() => {
if (hasPerFileLengthUnit) {
const newCode = changeKclSettings(codeManager.code, {
defaultLengthUnits: unitLengthToUnitLen(unit),
defaultAngleUnits: { type: 'Degrees' },
})
if (err(newCode)) {
toast.error(
`Failed to set per-file units: ${newCode.message}`
)
} else {
codeManager.updateCodeStateEditor(newCode)
Promise.all([
codeManager.writeToFile(),
kclManager.executeCode(),
])
.then(() => {
toast.success(`Updated per-file units to ${unit}`)
})
.catch(reportRejection)
}
} else {
settings.send({ settings.send({
type: 'set.modeling.defaultUnit', type: 'set.modeling.defaultUnit',
data: { data: {
@ -38,11 +81,12 @@ export function UnitsMenu() {
value: unit, value: unit,
}, },
}) })
}
close() close()
}} }}
> >
<span className="flex-1">{baseUnitLabels[unit]}</span> <span className="flex-1">{baseUnitLabels[unit]}</span>
{unit === settings.context.modeling.defaultUnit.current && ( {unit === lengthSetting && (
<span className="text-chalkboard-60">current</span> <span className="text-chalkboard-60">current</span>
)} )}
</button> </button>

View File

@ -25,7 +25,7 @@ import {
SourceRange, SourceRange,
topLevelRange, topLevelRange,
} from 'lang/wasm' } from 'lang/wasm'
import { getNodeFromPath } from './queryAst' import { getNodeFromPath, getSettingsAnnotation } from './queryAst'
import { codeManager, editorManager, sceneInfra } from 'lib/singletons' import { codeManager, editorManager, sceneInfra } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint' import { Diagnostic } from '@codemirror/lint'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
@ -35,6 +35,7 @@ import {
ModelingCmdReq_type, ModelingCmdReq_type,
} from '@kittycad/lib/dist/types/src/models' } from '@kittycad/lib/dist/types/src/models'
import { Operation } from 'wasm-lib/kcl/bindings/Operation' import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { KclSettingsAnnotation } from 'lib/settings/settingsTypes'
interface ExecuteArgs { interface ExecuteArgs {
ast?: Node<Program> ast?: Node<Program>
@ -70,6 +71,7 @@ export class KclManager {
private _wasmInitFailed = true private _wasmInitFailed = true
private _hasErrors = false private _hasErrors = false
private _switchedFiles = false private _switchedFiles = false
private _fileSettings: KclSettingsAnnotation = {}
engineCommandManager: EngineCommandManager engineCommandManager: EngineCommandManager
@ -368,6 +370,13 @@ export class KclManager {
await this.disableSketchMode() await this.disableSketchMode()
} }
let fileSettings = getSettingsAnnotation(ast)
if (err(fileSettings)) {
console.error(fileSettings)
fileSettings = {}
}
this.fileSettings = fileSettings
this.logs = logs this.logs = logs
this.errors = errors this.errors = errors
// Do not add the errors since the program was interrupted and the error is not a real KCL error // Do not add the errors since the program was interrupted and the error is not a real KCL error
@ -699,6 +708,14 @@ export class KclManager {
_isAstEmpty(ast: Node<Program>) { _isAstEmpty(ast: Node<Program>) {
return ast.start === 0 && ast.end === 0 && ast.body.length === 0 return ast.start === 0 && ast.end === 0 && ast.body.length === 0
} }
get fileSettings() {
return this._fileSettings
}
set fileSettings(settings: KclSettingsAnnotation) {
this._fileSettings = settings
}
} }
const defaultSelectionFilter: EntityType_type[] = [ const defaultSelectionFilter: EntityType_type[] = [

View File

@ -23,6 +23,9 @@ import {
VariableDeclaration, VariableDeclaration,
VariableDeclarator, VariableDeclarator,
recast, recast,
kclSettings,
unitLenToUnitLength,
unitAngToUnitAngle,
} from './wasm' } from './wasm'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { createIdentifier, splitPathAtLastIndex } from './modifyAst' import { createIdentifier, splitPathAtLastIndex } from './modifyAst'
@ -38,6 +41,7 @@ import { ImportStatement } from 'wasm-lib/kcl/bindings/ImportStatement'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
import { findKwArg } from './util' import { findKwArg } from './util'
import { codeRefFromRange } from './std/artifactGraph' import { codeRefFromRange } from './std/artifactGraph'
import { KclSettingsAnnotation } from 'lib/settings/settingsTypes'
export const LABELED_ARG_FIELD = 'LabeledArg -> Arg' export const LABELED_ARG_FIELD = 'LabeledArg -> Arg'
export const ARG_INDEX_FIELD = 'arg index' export const ARG_INDEX_FIELD = 'arg index'
@ -866,3 +870,24 @@ export function getObjExprProperty(
if (index === -1) return null if (index === -1) return null
return { expr: node.properties[index].value, index } return { expr: node.properties[index].value, index }
} }
/**
* Given KCL, returns the settings annotation object if it exists.
*/
export function getSettingsAnnotation(
kcl: string | Node<Program>
): KclSettingsAnnotation | Error {
const metaSettings = kclSettings(kcl)
if (err(metaSettings)) return metaSettings
const settings: KclSettingsAnnotation = {}
// No settings in the KCL.
if (!metaSettings) return settings
settings.defaultLengthUnit = unitLenToUnitLength(
metaSettings.defaultLengthUnits
)
settings.defaultAngleUnit = unitAngToUnitAngle(metaSettings.defaultAngleUnits)
return settings
}

View File

@ -18,6 +18,7 @@ import {
default_project_settings, default_project_settings,
base64_decode, base64_decode,
clear_scene_and_bust_cache, clear_scene_and_bust_cache,
kcl_settings,
change_kcl_settings, change_kcl_settings,
reloadModule, reloadModule,
} from 'lib/wasm_lib_wrapper' } from 'lib/wasm_lib_wrapper'
@ -58,6 +59,9 @@ import { Artifact } from './std/artifactGraph'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { NumericSuffix } from 'wasm-lib/kcl/bindings/NumericSuffix' import { NumericSuffix } from 'wasm-lib/kcl/bindings/NumericSuffix'
import { MetaSettings } from 'wasm-lib/kcl/bindings/MetaSettings' import { MetaSettings } from 'wasm-lib/kcl/bindings/MetaSettings'
import { UnitAngle, UnitLength } from 'wasm-lib/kcl/bindings/ModelingCmd'
import { UnitLen } from 'wasm-lib/kcl/bindings/UnitLen'
import { UnitAngle as UnitAng } from 'wasm-lib/kcl/bindings/UnitAngle'
export type { Artifact } from 'wasm-lib/kcl/bindings/Artifact' export type { Artifact } from 'wasm-lib/kcl/bindings/Artifact'
export type { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact' export type { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact'
@ -857,8 +861,35 @@ export function base64Decode(base64: string): ArrayBuffer | Error {
} }
} }
/// Change the meta settings for the kcl file. /**
/// Returns the new kcl string with the updated settings. * Get the meta settings for the KCL. If no settings were set in the file,
* returns null.
*/
export function kclSettings(
kcl: string | Node<Program>
): MetaSettings | null | Error {
let program: Node<Program>
if (typeof kcl === 'string') {
const parseResult = parse(kcl)
if (err(parseResult)) return parseResult
if (!resultIsOk(parseResult)) {
return new Error(`parse result had errors`, { cause: parseResult })
}
program = parseResult.program
} else {
program = kcl
}
try {
return kcl_settings(JSON.stringify(program))
} catch (e) {
return new Error('Caught error getting kcl settings', { cause: e })
}
}
/**
* Change the meta settings for the kcl file.
* @returns the new kcl string with the updated settings.
*/
export function changeKclSettings( export function changeKclSettings(
kcl: string, kcl: string,
settings: MetaSettings settings: MetaSettings
@ -866,7 +897,59 @@ export function changeKclSettings(
try { try {
return change_kcl_settings(kcl, JSON.stringify(settings)) return change_kcl_settings(kcl, JSON.stringify(settings))
} catch (e) { } catch (e) {
console.error('Caught error changing kcl settings: ' + e) console.error('Caught error changing kcl settings', e)
return new Error('Caught error changing kcl settings: ' + e) return new Error('Caught error changing kcl settings', { cause: e })
}
}
/**
* Convert a `UnitLength_type` to a `UnitLen`
*/
export function unitLengthToUnitLen(input: UnitLength): UnitLen {
switch (input) {
case 'm':
return { type: 'M' }
case 'cm':
return { type: 'Cm' }
case 'yd':
return { type: 'Yards' }
case 'ft':
return { type: 'Feet' }
case 'in':
return { type: 'Inches' }
default:
return { type: 'Mm' }
}
}
/**
* Convert `UnitLen` to `UnitLength_type`.
*/
export function unitLenToUnitLength(input: UnitLen): UnitLength {
switch (input.type) {
case 'M':
return 'm'
case 'Cm':
return 'cm'
case 'Yards':
return 'yd'
case 'Feet':
return 'ft'
case 'Inches':
return 'in'
default:
return 'mm'
}
}
/**
* Convert `UnitAngle` to `UnitAngle_type`.
*/
export function unitAngToUnitAngle(input: UnitAng): UnitAngle {
switch (input.type) {
case 'Radians':
return 'radians'
default:
return 'degrees'
} }
} }

View File

@ -4,6 +4,10 @@ import { AtLeast, PathValue, Paths } from 'lib/types'
import { CommandArgumentConfig } from 'lib/commandTypes' import { CommandArgumentConfig } from 'lib/commandTypes'
import { Themes } from 'lib/theme' import { Themes } from 'lib/theme'
import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType' import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType'
import {
UnitAngle_type,
UnitLength_type,
} from '@kittycad/lib/dist/types/src/models'
import { CameraOrbitType } from 'wasm-lib/kcl/bindings/CameraOrbitType' import { CameraOrbitType } from 'wasm-lib/kcl/bindings/CameraOrbitType'
export interface SettingsViaQueryString { export interface SettingsViaQueryString {
@ -138,3 +142,12 @@ type RecursiveSettingsPayloads<T> = {
} }
export type SaveSettingsPayload = RecursiveSettingsPayloads<typeof settings> export type SaveSettingsPayload = RecursiveSettingsPayloads<typeof settings>
/**
* Annotation names for default units are defined on rust side in
* src/wasm-lib/kcl/src/execution/annotations.rs
*/
export interface KclSettingsAnnotation {
defaultLengthUnit?: UnitLength_type
defaultAngleUnit?: UnitAngle_type
}

View File

@ -26,6 +26,7 @@ import {
default_project_settings as DefaultProjectSettings, default_project_settings as DefaultProjectSettings,
base64_decode as Base64Decode, base64_decode as Base64Decode,
clear_scene_and_bust_cache as ClearSceneAndBustCache, clear_scene_and_bust_cache as ClearSceneAndBustCache,
kcl_settings as KclSettings,
change_kcl_settings as ChangeKclSettings, change_kcl_settings as ChangeKclSettings,
} from '../wasm-lib/pkg/wasm_lib' } from '../wasm-lib/pkg/wasm_lib'
@ -111,6 +112,9 @@ export const clear_scene_and_bust_cache: typeof ClearSceneAndBustCache = (
) => { ) => {
return getModule().clear_scene_and_bust_cache(...args) return getModule().clear_scene_and_bust_cache(...args)
} }
export const kcl_settings: typeof KclSettings = (...args) => {
return getModule().kcl_settings(...args)
}
export const change_kcl_settings: typeof ChangeKclSettings = (...args) => { export const change_kcl_settings: typeof ChangeKclSettings = (...args) => {
return getModule().change_kcl_settings(...args) return getModule().change_kcl_settings(...args)
} }

View File

@ -159,7 +159,7 @@ impl Program {
} }
/// Get the meta settings for the kcl file from the annotations. /// Get the meta settings for the kcl file from the annotations.
pub fn get_meta_settings(&self) -> Result<Option<crate::MetaSettings>, KclError> { pub fn meta_settings(&self) -> Result<Option<crate::MetaSettings>, KclError> {
self.ast.get_meta_settings() self.ast.get_meta_settings()
} }

View File

@ -539,6 +539,18 @@ pub fn calculate_circle_from_3_points(ax: f64, ay: f64, bx: f64, by: f64, cx: f6
} }
} }
/// Takes a parsed KCL program and returns the Meta settings. If it's not
/// found, null is returned.
#[wasm_bindgen]
pub fn kcl_settings(program_json: &str) -> Result<JsValue, String> {
console_error_panic_hook::set_once();
let program: Program = serde_json::from_str(program_json).map_err(|e| e.to_string())?;
let settings = program.meta_settings().map_err(|e| e.to_string())?;
JsValue::from_serde(&settings).map_err(|e| e.to_string())
}
/// Takes a kcl string and Meta settings and changes the meta settings in the kcl string. /// Takes a kcl string and Meta settings and changes the meta settings in the kcl string.
#[wasm_bindgen] #[wasm_bindgen]
pub fn change_kcl_settings(code: &str, settings_str: &str) -> Result<String, String> { pub fn change_kcl_settings(code: &str, settings_str: &str) -> Result<String, String> {