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 { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { changeKclSettings, unitLengthToUnitLen } from 'lang/wasm'
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() {
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 (
<Popover className="relative pointer-events-auto">
{({ 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>
<span className="sr-only">Current units are:&nbsp;</span>
{settings.context.modeling.defaultUnit.current}
{lengthSetting}
</Popover.Button>
<Popover.Panel
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
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={() => {
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({
type: 'set.modeling.defaultUnit',
data: {
@ -38,11 +81,12 @@ export function UnitsMenu() {
value: unit,
},
})
}
close()
}}
>
<span className="flex-1">{baseUnitLabels[unit]}</span>
{unit === settings.context.modeling.defaultUnit.current && (
{unit === lengthSetting && (
<span className="text-chalkboard-60">current</span>
)}
</button>

View File

@ -25,7 +25,7 @@ import {
SourceRange,
topLevelRange,
} from 'lang/wasm'
import { getNodeFromPath } from './queryAst'
import { getNodeFromPath, getSettingsAnnotation } from './queryAst'
import { codeManager, editorManager, sceneInfra } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint'
import { markOnce } from 'lib/performance'
@ -35,6 +35,7 @@ import {
ModelingCmdReq_type,
} from '@kittycad/lib/dist/types/src/models'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { KclSettingsAnnotation } from 'lib/settings/settingsTypes'
interface ExecuteArgs {
ast?: Node<Program>
@ -70,6 +71,7 @@ export class KclManager {
private _wasmInitFailed = true
private _hasErrors = false
private _switchedFiles = false
private _fileSettings: KclSettingsAnnotation = {}
engineCommandManager: EngineCommandManager
@ -368,6 +370,13 @@ export class KclManager {
await this.disableSketchMode()
}
let fileSettings = getSettingsAnnotation(ast)
if (err(fileSettings)) {
console.error(fileSettings)
fileSettings = {}
}
this.fileSettings = fileSettings
this.logs = logs
this.errors = errors
// 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>) {
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[] = [

View File

@ -23,6 +23,9 @@ import {
VariableDeclaration,
VariableDeclarator,
recast,
kclSettings,
unitLenToUnitLength,
unitAngToUnitAngle,
} from './wasm'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
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 { findKwArg } from './util'
import { codeRefFromRange } from './std/artifactGraph'
import { KclSettingsAnnotation } from 'lib/settings/settingsTypes'
export const LABELED_ARG_FIELD = 'LabeledArg -> Arg'
export const ARG_INDEX_FIELD = 'arg index'
@ -866,3 +870,24 @@ export function getObjExprProperty(
if (index === -1) return null
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,
base64_decode,
clear_scene_and_bust_cache,
kcl_settings,
change_kcl_settings,
reloadModule,
} from 'lib/wasm_lib_wrapper'
@ -58,6 +59,9 @@ import { Artifact } from './std/artifactGraph'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { NumericSuffix } from 'wasm-lib/kcl/bindings/NumericSuffix'
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 { 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(
kcl: string,
settings: MetaSettings
@ -866,7 +897,59 @@ export function changeKclSettings(
try {
return change_kcl_settings(kcl, JSON.stringify(settings))
} catch (e) {
console.error('Caught error changing kcl settings: ' + e)
return new Error('Caught error changing kcl settings: ' + e)
console.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 { Themes } from 'lib/theme'
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'
export interface SettingsViaQueryString {
@ -138,3 +142,12 @@ type RecursiveSettingsPayloads<T> = {
}
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,
base64_decode as Base64Decode,
clear_scene_and_bust_cache as ClearSceneAndBustCache,
kcl_settings as KclSettings,
change_kcl_settings as ChangeKclSettings,
} 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)
}
export const kcl_settings: typeof KclSettings = (...args) => {
return getModule().kcl_settings(...args)
}
export const change_kcl_settings: typeof ChangeKclSettings = (...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.
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()
}

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.
#[wasm_bindgen]
pub fn change_kcl_settings(code: &str, settings_str: &str) -> Result<String, String> {