Compare commits
	
		
			3 Commits
		
	
	
		
			kcl-70
			...
			lsp-colors
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 86ae7a989f | |||
| 334145f0be | |||
| c24073b6ae | 
| @ -834,7 +834,13 @@ test('theme persists', async ({ page, context }) => { | ||||
| }) | ||||
|  | ||||
| test.describe('code color goober', { tag: '@snapshot' }, () => { | ||||
|   test('code color goober', async ({ page, context, scene, cmdBar }) => { | ||||
|   test('code color goober', async ({ | ||||
|     page, | ||||
|     context, | ||||
|     scene, | ||||
|     cmdBar, | ||||
|     editor, | ||||
|   }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await context.addInitScript(async () => { | ||||
|       localStorage.setItem( | ||||
| @ -879,6 +885,7 @@ sweepSketch = startSketchOn(XY) | ||||
|     context, | ||||
|     scene, | ||||
|     cmdBar, | ||||
|     editor, | ||||
|   }) => { | ||||
|     const u = await getUtils(page) | ||||
|     await context.addInitScript(async () => { | ||||
|  | ||||
| @ -41,6 +41,14 @@ interface LSPRequestMap { | ||||
|     LSP.DefinitionParams, | ||||
|     LSP.Definition | LSP.DefinitionLink[] | null, | ||||
|   ] | ||||
|   'textDocument/documentColor': [ | ||||
|     LSP.DocumentColorParams, | ||||
|     LSP.ColorInformation[] | null, | ||||
|   ] | ||||
|   'textDocument/colorPresentation': [ | ||||
|     LSP.ColorPresentationParams, | ||||
|     LSP.ColorPresentation[] | null, | ||||
|   ] | ||||
| } | ||||
|  | ||||
| // Client to server | ||||
| @ -229,6 +237,22 @@ export class LanguageServerClient { | ||||
|     return await this.request('textDocument/definition', params) | ||||
|   } | ||||
|  | ||||
|   async textDocumentDocumentColor(params: LSP.DocumentColorParams) { | ||||
|     const serverCapabilities = this.getServerCapabilities() | ||||
|     if (!serverCapabilities.colorProvider) { | ||||
|       return null | ||||
|     } | ||||
|     return await this.request('textDocument/documentColor', params) | ||||
|   } | ||||
|  | ||||
|   async textDocumentColorPresentation(params: LSP.ColorPresentationParams) { | ||||
|     const serverCapabilities = this.getServerCapabilities() | ||||
|     if (!serverCapabilities.colorProvider) { | ||||
|       return null | ||||
|     } | ||||
|     return await this.request('textDocument/colorPresentation', params) | ||||
|   } | ||||
|  | ||||
|   attachPlugin(plugin: LanguageServerPlugin) { | ||||
|     this.plugins.push(plugin) | ||||
|   } | ||||
|  | ||||
| @ -21,6 +21,7 @@ export { | ||||
|   lspRenameEvent, | ||||
|   lspSemanticTokensEvent, | ||||
|   lspCodeActionEvent, | ||||
|   lspColorUpdateEvent, | ||||
| } from './plugin/annotation' | ||||
| export { | ||||
|   LanguageServerPlugin, | ||||
|  | ||||
| @ -6,6 +6,7 @@ export enum LspAnnotation { | ||||
|   Diagnostics = 'diagnostics', | ||||
|   Rename = 'rename', | ||||
|   CodeAction = 'code-action', | ||||
|   ColorUpdate = 'color-update', | ||||
| } | ||||
|  | ||||
| const lspEvent = Annotation.define<LspAnnotation>() | ||||
| @ -14,3 +15,4 @@ export const lspFormatCodeEvent = lspEvent.of(LspAnnotation.FormatCode) | ||||
| export const lspDiagnosticsEvent = lspEvent.of(LspAnnotation.Diagnostics) | ||||
| export const lspRenameEvent = lspEvent.of(LspAnnotation.Rename) | ||||
| export const lspCodeActionEvent = lspEvent.of(LspAnnotation.CodeAction) | ||||
| export const lspColorUpdateEvent = lspEvent.of(LspAnnotation.ColorUpdate) | ||||
|  | ||||
							
								
								
									
										278
									
								
								packages/codemirror-lsp-client/src/plugin/colors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								packages/codemirror-lsp-client/src/plugin/colors.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,278 @@ | ||||
| import { | ||||
|   StateEffect, | ||||
|   StateField, | ||||
|   type Extension, | ||||
|   type Range, | ||||
| } from '@codemirror/state' | ||||
| import { | ||||
|   Decoration, | ||||
|   type DecorationSet, | ||||
|   EditorView, | ||||
|   ViewPlugin, | ||||
|   WidgetType, | ||||
|   type ViewUpdate, | ||||
| } from '@codemirror/view' | ||||
|  | ||||
| import type { LanguageServerPlugin } from './lsp' | ||||
| import { lspColorUpdateEvent } from './annotation' | ||||
| import { isArray } from '../lib/utils' | ||||
| import { offsetToPos, posToOffset, posToOffsetOrZero } from './util' | ||||
| import type * as LSP from 'vscode-languageserver-protocol' | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* ----------  original helpers / widget / color utilities  ---------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| interface PickerState { | ||||
|   from: number | ||||
|   to: number | ||||
|   red: number | ||||
|   green: number | ||||
|   blue: number | ||||
|   alpha: number | ||||
| } | ||||
|  | ||||
| export interface WidgetOptions extends PickerState { | ||||
|   color: string | ||||
| } | ||||
|  | ||||
| export type ColorData = Omit<WidgetOptions, 'from' | 'to'> | ||||
|  | ||||
| const pickerState = new WeakMap<HTMLInputElement, PickerState>() | ||||
|  | ||||
| function rgbaToHex(color: LSP.Color): string { | ||||
|   return ( | ||||
|     '#' + | ||||
|     [color.red, color.green, color.blue] | ||||
|       .map((c) => | ||||
|         Math.round(c * 255) | ||||
|           .toString(16) | ||||
|           .padStart(2, '0') | ||||
|       ) | ||||
|       .join('') | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function hexToRGBComponents(hex: string): number[] { | ||||
|   const r = hex.slice(1, 3) | ||||
|   const g = hex.slice(3, 5) | ||||
|   const b = hex.slice(5, 7) | ||||
|   return [parseInt(r, 16) / 255, parseInt(g, 16) / 255, parseInt(b, 16) / 255] | ||||
| } | ||||
|  | ||||
| async function discoverColorsViaLsp( | ||||
|   view: EditorView, | ||||
|   plugin: LanguageServerPlugin | ||||
| ): Promise<WidgetOptions | Array<WidgetOptions> | null> { | ||||
|   const responses = await plugin.requestDocumentColors() | ||||
|   if (!responses) return null | ||||
|  | ||||
|   const colors: Array<WidgetOptions> = [] | ||||
|   for (const color of responses) { | ||||
|     if (!color.range || !color.color) continue | ||||
|  | ||||
|     const { start, end } = color.range | ||||
|     const from = posToOffset(view.state.doc, start) | ||||
|     const to = posToOffset(view.state.doc, end) | ||||
|     if (from == null || to == null) continue | ||||
|  | ||||
|     colors.push({ | ||||
|       color: rgbaToHex(color.color), | ||||
|       ...color.color, | ||||
|       from, | ||||
|       to, | ||||
|     }) | ||||
|   } | ||||
|   return colors | ||||
| } | ||||
|  | ||||
| async function colorPickersDecorations( | ||||
|   view: EditorView, | ||||
|   plugin: LanguageServerPlugin | ||||
| ): Promise<DecorationSet> { | ||||
|   const widgets: Array<Range<Decoration>> = [] | ||||
|   const maybe = await discoverColorsViaLsp(view, plugin) | ||||
|   if (!maybe) return Decoration.none | ||||
|  | ||||
|   const optionsList = isArray(maybe) ? maybe : [maybe] | ||||
|   for (const wo of optionsList) { | ||||
|     widgets.push( | ||||
|       Decoration.widget({ | ||||
|         widget: new ColorPickerWidget(wo), | ||||
|         side: 1, | ||||
|       }).range(wo.from) | ||||
|     ) | ||||
|   } | ||||
|   return Decoration.set(widgets) | ||||
| } | ||||
|  | ||||
| export const wrapperClassName = 'cm-css-color-picker-wrapper' | ||||
|  | ||||
| class ColorPickerWidget extends WidgetType { | ||||
|   private readonly state: PickerState | ||||
|   private readonly color: string | ||||
|  | ||||
|   constructor({ color, ...state }: WidgetOptions) { | ||||
|     super() | ||||
|     this.state = state | ||||
|     this.color = color | ||||
|   } | ||||
|  | ||||
|   eq(other: ColorPickerWidget) { | ||||
|     return ( | ||||
|       other.color === this.color && | ||||
|       other.state.from === this.state.from && | ||||
|       other.state.to === this.state.to && | ||||
|       other.state.alpha === this.state.alpha | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   toDOM() { | ||||
|     const picker = document.createElement('input') | ||||
|     pickerState.set(picker, this.state) | ||||
|     picker.type = 'color' | ||||
|     picker.value = this.color | ||||
|  | ||||
|     const wrapper = document.createElement('span') | ||||
|     wrapper.appendChild(picker) | ||||
|     wrapper.className = wrapperClassName | ||||
|     return wrapper | ||||
|   } | ||||
|  | ||||
|   ignoreEvent() { | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const colorPickerTheme = EditorView.baseTheme({ | ||||
|   [`.${wrapperClassName}`]: { | ||||
|     display: 'inline-block', | ||||
|     outline: '1px solid #eee', | ||||
|     marginRight: '0.6ch', | ||||
|     height: '1em', | ||||
|     width: '1em', | ||||
|     transform: 'translateY(1px)', | ||||
|   }, | ||||
|   [`.${wrapperClassName} input[type="color"]`]: { | ||||
|     cursor: 'pointer', | ||||
|     height: '100%', | ||||
|     width: '100%', | ||||
|     padding: 0, | ||||
|     border: 'none', | ||||
|     '&::-webkit-color-swatch-wrapper': { padding: 0 }, | ||||
|     '&::-webkit-color-swatch': { border: 'none' }, | ||||
|     '&::-moz-color-swatch': { border: 'none' }, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* -------------------  ✅  new state machinery  -------------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| // Effect that carries a fresh DecorationSet | ||||
| const setColorDecorations = StateEffect.define<DecorationSet>() | ||||
|  | ||||
| // Field that stores the current DecorationSet | ||||
| const colorDecorationsField = StateField.define<DecorationSet>({ | ||||
|   create: () => Decoration.none, | ||||
|   update(value, tr) { | ||||
|     value = value.map(tr.changes) | ||||
|     for (const e of tr.effects) if (e.is(setColorDecorations)) value = e.value | ||||
|     return value | ||||
|   }, | ||||
|   provide: (f) => EditorView.decorations.from(f), | ||||
| }) | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* ------------------  original ViewPlugin, patched  ---------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| export const makeColorPicker = (plugin: ViewPlugin<LanguageServerPlugin>) => | ||||
|   ViewPlugin.fromClass( | ||||
|     class ColorPickerViewPlugin { | ||||
|       plugin: LanguageServerPlugin | null | ||||
|  | ||||
|       constructor(view: EditorView) { | ||||
|         this.plugin = view.plugin(plugin) | ||||
|         if (!this.plugin) return | ||||
|  | ||||
|         // initial async load → dispatch decorations | ||||
|         // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|         colorPickersDecorations(view, this.plugin).then((deco) => { | ||||
|           view.dispatch({ effects: setColorDecorations.of(deco) }) | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       async update(update: ViewUpdate) { | ||||
|         if (!this.plugin) return | ||||
|         if (!(update.docChanged || update.viewportChanged)) return | ||||
|  | ||||
|         const deco = await colorPickersDecorations(update.view, this.plugin) | ||||
|         update.view.dispatch({ effects: setColorDecorations.of(deco) }) | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       eventHandlers: { | ||||
|         change: (e: Event, view: EditorView) => { | ||||
|           // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||||
|           colorPickerChange(e, view, plugin) | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|   ) | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* --------------------  unchanged event handler  ------------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| async function colorPickerChange( | ||||
|   e: Event, | ||||
|   view: EditorView, | ||||
|   plugin: ViewPlugin<LanguageServerPlugin> | ||||
| ): Promise<boolean> { | ||||
|   const value = view.plugin(plugin) | ||||
|   if (!value) return false | ||||
|  | ||||
|   const target = e.target as HTMLInputElement | ||||
|   if ( | ||||
|     target.nodeName !== 'INPUT' || | ||||
|     !target.parentElement?.classList.contains(wrapperClassName) | ||||
|   ) | ||||
|     return false | ||||
|  | ||||
|   const data = pickerState.get(target)! | ||||
|   const converted = target.value + data.alpha | ||||
|   const [red, green, blue] = hexToRGBComponents(converted) | ||||
|  | ||||
|   const responses = await value.requestColorPresentation( | ||||
|     { red, green, blue, alpha: data.alpha }, | ||||
|     { | ||||
|       start: offsetToPos(view.state.doc, data.from), | ||||
|       end: offsetToPos(view.state.doc, data.to), | ||||
|     } | ||||
|   ) | ||||
|   if (!responses?.length) return false | ||||
|  | ||||
|   for (const resp of responses) { | ||||
|     const changes = resp.textEdit | ||||
|       ? { | ||||
|           from: posToOffsetOrZero(view.state.doc, resp.textEdit.range.start), | ||||
|           to: posToOffsetOrZero(view.state.doc, resp.textEdit.range.end), | ||||
|           insert: resp.textEdit.newText, | ||||
|         } | ||||
|       : { from: data.from, to: data.to, insert: resp.label } | ||||
|  | ||||
|     view.dispatch({ changes, annotations: [lspColorUpdateEvent] }) | ||||
|   } | ||||
|   return true | ||||
| } | ||||
|  | ||||
| /* ------------------------------------------------------------------ */ | ||||
| /* -------------------------  public API  --------------------------- */ | ||||
| /* ------------------------------------------------------------------ */ | ||||
|  | ||||
| export default function lspColorsExt( | ||||
|   plugin: ViewPlugin<LanguageServerPlugin> | ||||
| ): Extension { | ||||
|   return [colorDecorationsField, makeColorPicker(plugin), colorPickerTheme] | ||||
| } | ||||
| @ -48,6 +48,7 @@ import { isArray } from '../lib/utils' | ||||
| import lspGoToDefinitionExt from './go-to-definition' | ||||
| import lspRenameExt from './rename' | ||||
| import lspSignatureHelpExt from './signature-help' | ||||
| import lspColorsExt from './colors' | ||||
|  | ||||
| const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '') | ||||
| export const docPathFacet = Facet.define<string, string>({ | ||||
| @ -534,6 +535,37 @@ export class LanguageServerPlugin implements PluginValue { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   async requestDocumentColors() { | ||||
|     if ( | ||||
|       !(this.client.getServerCapabilities().colorProvider && this.client.ready) | ||||
|     ) { | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const result = await this.client.textDocumentDocumentColor({ | ||||
|       textDocument: { uri: this.getDocUri() }, | ||||
|     }) | ||||
|  | ||||
|     if (!result) return | ||||
|     return result | ||||
|   } | ||||
|  | ||||
|   async requestColorPresentation(color: LSP.Color, range: LSP.Range) { | ||||
|     if ( | ||||
|       !(this.client.getServerCapabilities().colorProvider && this.client.ready) | ||||
|     ) { | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const result = await this.client.textDocumentColorPresentation({ | ||||
|       textDocument: { uri: this.getDocUri() }, | ||||
|       color, | ||||
|       range, | ||||
|     }) | ||||
|     if (!result) return | ||||
|     return result | ||||
|   } | ||||
|  | ||||
|   async requestRename( | ||||
|     view: EditorView, | ||||
|     { line, character }: { line: number; character: number } | ||||
| @ -1318,6 +1350,7 @@ export class LanguageServerPluginSpec | ||||
|     return [ | ||||
|       linter(null), | ||||
|       lspAutocompleteExt(plugin), | ||||
|       lspColorsExt(plugin), | ||||
|       lspFormatExt(plugin), | ||||
|       lspGoToDefinitionExt(plugin), | ||||
|       lspHoverExt(plugin), | ||||
|  | ||||
							
								
								
									
										14
									
								
								rust/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								rust/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -535,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" | ||||
| dependencies = [ | ||||
|  "lazy_static", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -963,7 +963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -1746,7 +1746,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" | ||||
| dependencies = [ | ||||
|  "hermit-abi", | ||||
|  "libc", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -2987,7 +2987,7 @@ dependencies = [ | ||||
|  "once_cell", | ||||
|  "socket2", | ||||
|  "tracing", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -3306,7 +3306,7 @@ dependencies = [ | ||||
|  "errno", | ||||
|  "libc", | ||||
|  "linux-raw-sys", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -3900,7 +3900,7 @@ dependencies = [ | ||||
|  "getrandom 0.3.1", | ||||
|  "once_cell", | ||||
|  "rustix", | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @ -4753,7 +4753,7 @@ version = "0.1.9" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" | ||||
| dependencies = [ | ||||
|  "windows-sys 0.52.0", | ||||
|  "windows-sys 0.59.0", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
|  | ||||
| @ -4220,8 +4220,8 @@ sketch001 = startSketchOn(XY) | ||||
|         result, | ||||
|         vec![tower_lsp::lsp_types::ColorInformation { | ||||
|             range: tower_lsp::lsp_types::Range { | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 24 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 33 }, | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 25 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 32 }, | ||||
|             }, | ||||
|             color: tower_lsp::lsp_types::Color { | ||||
|                 red: 1.0, | ||||
| @ -4272,8 +4272,8 @@ sketch001 = startSketchOn(XY) | ||||
|         result, | ||||
|         vec![tower_lsp::lsp_types::ColorInformation { | ||||
|             range: tower_lsp::lsp_types::Range { | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 24 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 33 }, | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 25 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 32 }, | ||||
|             }, | ||||
|             color: tower_lsp::lsp_types::Color { | ||||
|                 red: 1.0, | ||||
| @ -4291,8 +4291,8 @@ sketch001 = startSketchOn(XY) | ||||
|                 uri: "file:///test.kcl".try_into().unwrap(), | ||||
|             }, | ||||
|             range: tower_lsp::lsp_types::Range { | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 24 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 33 }, | ||||
|                 start: tower_lsp::lsp_types::Position { line: 4, character: 25 }, | ||||
|                 end: tower_lsp::lsp_types::Position { line: 4, character: 32 }, | ||||
|             }, | ||||
|             color: tower_lsp::lsp_types::Color { | ||||
|                 red: 1.0, | ||||
|  | ||||
| @ -438,8 +438,15 @@ impl Node<Program> { | ||||
|         let add_color = |literal: &Node<Literal>| { | ||||
|             // Check if the string is a color. | ||||
|             if let Some(c) = literal.value.is_color() { | ||||
|                 let source_range = literal.as_source_range(); | ||||
|                 // We subtract 1 from either side because of the "'s in the literal. | ||||
|                 let fixed_source_range = SourceRange::new( | ||||
|                     source_range.start() + 1, | ||||
|                     source_range.end() - 1, | ||||
|                     source_range.module_id(), | ||||
|                 ); | ||||
|                 let color = ColorInformation { | ||||
|                     range: literal.as_source_range().to_lsp_range(code), | ||||
|                     range: fixed_source_range.to_lsp_range(code), | ||||
|                     color: tower_lsp::lsp_types::Color { | ||||
|                         red: c.r, | ||||
|                         green: c.g, | ||||
| @ -498,7 +505,11 @@ impl Node<Program> { | ||||
|         crate::walk::walk(self, |node: crate::walk::Node<'a>| { | ||||
|             match node { | ||||
|                 crate::walk::Node::Literal(literal) => { | ||||
|                     if literal.start == pos_start && literal.end == pos_end && literal.value.is_color().is_some() { | ||||
|                     // Account for the quotes in the literal. | ||||
|                     if (literal.start + 1) == pos_start | ||||
|                         && (literal.end - 1) == pos_end | ||||
|                         && literal.value.is_color().is_some() | ||||
|                     { | ||||
|                         found.replace(true); | ||||
|                         return Ok(true); | ||||
|                     } | ||||
|  | ||||
| @ -2729,6 +2729,17 @@ fn ty(i: &mut TokenSlice) -> PResult<Token> { | ||||
|     keyword(i, "type") | ||||
| } | ||||
|  | ||||
| fn any_keyword(i: &mut TokenSlice) -> PResult<Token> { | ||||
|     any.try_map(|token: Token| match token.token_type { | ||||
|         TokenType::Keyword => Ok(token), | ||||
|         _ => Err(CompilationError::fatal( | ||||
|             token.as_source_range(), | ||||
|             "expected some reserved keyword".to_owned(), | ||||
|         )), | ||||
|     }) | ||||
|     .parse_next(i) | ||||
| } | ||||
|  | ||||
| fn keyword(i: &mut TokenSlice, expected: &str) -> PResult<Token> { | ||||
|     any.try_map(|token: Token| match token.token_type { | ||||
|         TokenType::Keyword if token.value == expected => Ok(token), | ||||
| @ -3143,12 +3154,14 @@ fn fn_call_kw(i: &mut TokenSlice) -> PResult<Node<CallExpressionKw>> { | ||||
|         NonCode(Node<NonCodeNode>), | ||||
|         LabeledArg(LabeledArg), | ||||
|         UnlabeledArg(Expr), | ||||
|         Keyword(Token), | ||||
|     } | ||||
|     let initial_unlabeled_arg = opt((expression, comma, opt(whitespace)).map(|(arg, _, _)| arg)).parse_next(i)?; | ||||
|     let args: Vec<_> = repeat( | ||||
|         0.., | ||||
|         alt(( | ||||
|             terminated(non_code_node.map(ArgPlace::NonCode), whitespace), | ||||
|             terminated(any_keyword.map(ArgPlace::Keyword), whitespace), | ||||
|             terminated(labeled_argument, labeled_arg_separator).map(ArgPlace::LabeledArg), | ||||
|             expression.map(ArgPlace::UnlabeledArg), | ||||
|         )), | ||||
| @ -3164,6 +3177,18 @@ fn fn_call_kw(i: &mut TokenSlice) -> PResult<Node<CallExpressionKw>> { | ||||
|                 ArgPlace::LabeledArg(x) => { | ||||
|                     args.push(x); | ||||
|                 } | ||||
|                 ArgPlace::Keyword(kw) => { | ||||
|                     return Err(ErrMode::Cut( | ||||
|                         CompilationError::fatal( | ||||
|                             SourceRange::from(kw.clone()), | ||||
|                             format!( | ||||
|                                 "`{}` is not the name of an argument (it's a reserved keyword)", | ||||
|                                 kw.value | ||||
|                             ), | ||||
|                         ) | ||||
|                         .into(), | ||||
|                     )); | ||||
|                 } | ||||
|                 ArgPlace::UnlabeledArg(arg) => { | ||||
|                     let followed_by_equals = peek((opt(whitespace), equals)).parse_next(i).is_ok(); | ||||
|                     if followed_by_equals { | ||||
| @ -5055,6 +5080,30 @@ bar = 1 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_sensible_error_when_using_keyword_as_arg_label() { | ||||
|         for (i, program) in ["pow(2, fn = 8)"].into_iter().enumerate() { | ||||
|             let tokens = crate::parsing::token::lex(program, ModuleId::default()).unwrap(); | ||||
|             let err = match fn_call_kw.parse(tokens.as_slice()) { | ||||
|                 Err(e) => e, | ||||
|                 Ok(ast) => { | ||||
|                     eprintln!("{ast:#?}"); | ||||
|                     panic!("Expected this to error but it didn't"); | ||||
|                 } | ||||
|             }; | ||||
|             let cause = err.inner().cause.as_ref().unwrap(); | ||||
|             assert_eq!( | ||||
|                 cause.message, "`fn` is not the name of an argument (it's a reserved keyword)", | ||||
|                 "failed test {i}: {program}" | ||||
|             ); | ||||
|             assert_eq!( | ||||
|                 cause.source_range.start(), | ||||
|                 program.find("fn").unwrap(), | ||||
|                 "failed test {i}: {program}" | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_sensible_error_when_missing_rhs_of_obj_property() { | ||||
|         for (i, program) in ["{x = 1, y =}"].into_iter().enumerate() { | ||||
|  | ||||
| @ -3092,3 +3092,24 @@ mod error_revolve_on_edge_get_edge { | ||||
|         super::execute(TEST_NAME, true).await | ||||
|     } | ||||
| } | ||||
| mod sketch_on_face_union { | ||||
|     const TEST_NAME: &str = "sketch_on_face_union"; | ||||
|  | ||||
|     /// Test parsing KCL. | ||||
|     #[test] | ||||
|     fn parse() { | ||||
|         super::parse(TEST_NAME) | ||||
|     } | ||||
|  | ||||
|     /// Test that parsing and unparsing KCL produces the original KCL input. | ||||
|     #[tokio::test(flavor = "multi_thread")] | ||||
|     async fn unparse() { | ||||
|         super::unparse(TEST_NAME).await | ||||
|     } | ||||
|  | ||||
|     /// Test that KCL is executed correctly. | ||||
|     #[tokio::test(flavor = "multi_thread")] | ||||
|     async fn kcl_test_execute() { | ||||
|         super::execute(TEST_NAME, true).await | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -14,7 +14,7 @@ use super::{args::TyF64, DEFAULT_TOLERANCE}; | ||||
| use crate::{ | ||||
|     errors::{KclError, KclErrorDetails}, | ||||
|     execution::{types::RuntimeType, ExecState, KclValue, Solid}, | ||||
|     std::Args, | ||||
|     std::{patterns::GeometryTrait, Args}, | ||||
| }; | ||||
|  | ||||
| /// Union two or more solids into a single solid. | ||||
| @ -123,7 +123,7 @@ pub(crate) async fn inner_union( | ||||
|     let solid_out_id = exec_state.next_uuid(); | ||||
|  | ||||
|     let mut solid = solids[0].clone(); | ||||
|     solid.id = solid_out_id; | ||||
|     solid.set_id(solid_out_id); | ||||
|     let mut new_solids = vec![solid.clone()]; | ||||
|  | ||||
|     if args.ctx.no_engine_commands().await { | ||||
| @ -155,7 +155,7 @@ pub(crate) async fn inner_union( | ||||
|  | ||||
|     // If we have more solids, set those as well. | ||||
|     if !extra_solid_ids.is_empty() { | ||||
|         solid.id = extra_solid_ids[0]; | ||||
|         solid.set_id(extra_solid_ids[0]); | ||||
|         new_solids.push(solid.clone()); | ||||
|     } | ||||
|  | ||||
| @ -249,7 +249,7 @@ pub(crate) async fn inner_intersect( | ||||
|     let solid_out_id = exec_state.next_uuid(); | ||||
|  | ||||
|     let mut solid = solids[0].clone(); | ||||
|     solid.id = solid_out_id; | ||||
|     solid.set_id(solid_out_id); | ||||
|     let mut new_solids = vec![solid.clone()]; | ||||
|  | ||||
|     if args.ctx.no_engine_commands().await { | ||||
| @ -281,7 +281,7 @@ pub(crate) async fn inner_intersect( | ||||
|  | ||||
|     // If we have more solids, set those as well. | ||||
|     if !extra_solid_ids.is_empty() { | ||||
|         solid.id = extra_solid_ids[0]; | ||||
|         solid.set_id(extra_solid_ids[0]); | ||||
|         new_solids.push(solid.clone()); | ||||
|     } | ||||
|  | ||||
| @ -385,7 +385,7 @@ pub(crate) async fn inner_subtract( | ||||
|     let solid_out_id = exec_state.next_uuid(); | ||||
|  | ||||
|     let mut solid = solids[0].clone(); | ||||
|     solid.id = solid_out_id; | ||||
|     solid.set_id(solid_out_id); | ||||
|     let mut new_solids = vec![solid.clone()]; | ||||
|  | ||||
|     if args.ctx.no_engine_commands().await { | ||||
| @ -419,7 +419,7 @@ pub(crate) async fn inner_subtract( | ||||
|  | ||||
|     // If we have more solids, set those as well. | ||||
|     if !extra_solid_ids.is_empty() { | ||||
|         solid.id = extra_solid_ids[0]; | ||||
|         solid.set_id(extra_solid_ids[0]); | ||||
|         new_solids.push(solid.clone()); | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -598,7 +598,7 @@ fn array_to_point2d( | ||||
|         .map(|val| val.as_point2d().unwrap()) | ||||
| } | ||||
|  | ||||
| trait GeometryTrait: Clone { | ||||
| pub trait GeometryTrait: Clone { | ||||
|     type Set: Into<Vec<Self>> + Clone; | ||||
|     fn id(&self) -> Uuid; | ||||
|     fn original_id(&self) -> Uuid; | ||||
| @ -608,6 +608,7 @@ trait GeometryTrait: Clone { | ||||
|         source_ranges: Vec<SourceRange>, | ||||
|         exec_state: &mut ExecState, | ||||
|     ) -> Result<[TyF64; 3], KclError>; | ||||
|     #[allow(async_fn_in_trait)] | ||||
|     async fn flush_batch(args: &Args, exec_state: &mut ExecState, set: &Self::Set) -> Result<(), KclError>; | ||||
| } | ||||
|  | ||||
| @ -641,6 +642,8 @@ impl GeometryTrait for Solid { | ||||
|     type Set = Vec<Solid>; | ||||
|     fn set_id(&mut self, id: Uuid) { | ||||
|         self.id = id; | ||||
|         // We need this for in extrude.rs when you sketch on face. | ||||
|         self.sketch.id = id; | ||||
|     } | ||||
|  | ||||
|     fn id(&self) -> Uuid { | ||||
|  | ||||
							
								
								
									
										1060
									
								
								rust/kcl-lib/tests/sketch_on_face_union/artifact_commands.snap
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1060
									
								
								rust/kcl-lib/tests/sketch_on_face_union/artifact_commands.snap
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										3671
									
								
								rust/kcl-lib/tests/sketch_on_face_union/ast.snap
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3671
									
								
								rust/kcl-lib/tests/sketch_on_face_union/ast.snap
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										58
									
								
								rust/kcl-lib/tests/sketch_on_face_union/input.kcl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								rust/kcl-lib/tests/sketch_on_face_union/input.kcl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | ||||
| @settings(defaultLengthUnit = in) | ||||
|  | ||||
| // Define parameters | ||||
| trussSupportAngle = 15 | ||||
| height = 120 | ||||
| thickness = 4 | ||||
|  | ||||
| sketch001 = startSketchOn(YZ) | ||||
| profile001 = startProfile(sketch001, at = [60, 0]) | ||||
|   |> xLine(length = -120, tag = $bottomFace) | ||||
|   |> yLine(length = 12) | ||||
|   |> angledLine(angle = 25, endAbsoluteX = 0, tag = $tag001) | ||||
|   |> angledLine(angle = -25, endAbsoluteX = 60) | ||||
|   |> close() | ||||
|  | ||||
| profile002 = startProfile(sketch001, at = [60-thickness, thickness]) | ||||
|   |> xLine(endAbsolute = thickness/2) | ||||
|   |> yLine(endAbsolute = segEndY(tag001)-thickness) // update | ||||
|   |> angledLine(endAbsoluteX = profileStartX(%), angle = -25) | ||||
|   |> close(%) | ||||
|  | ||||
| profile003 = startProfile(sketch001, at = [-60+thickness, thickness]) | ||||
|   |> xLine(endAbsolute = -thickness/2) | ||||
|   |> yLine(endAbsolute = segEndY(tag001)-thickness) // update | ||||
|   |> angledLine(endAbsoluteX = profileStartX(%), angle = 205) | ||||
|   |> close(%) | ||||
|  | ||||
| profile004 = subtract2d(profile001, tool = profile002) | ||||
| subtract2d(profile001, tool = profile003) | ||||
|  | ||||
| body001 = extrude(profile001, length = 2) | ||||
|  | ||||
| sketch002 = startSketchOn(offsetPlane(YZ, offset = .1)) | ||||
| profile006 = startProfile(sketch002, at = [thickness/2-1, 14]) | ||||
|   |> angledLine(angle = 30, length = 25) | ||||
|   |> angledLine(angle = -25, length = 5) | ||||
|   |> angledLine(angle = 210, endAbsoluteX = profileStartX(%)) | ||||
|   |> close(%) | ||||
|   |> extrude(%, length = 1.8) | ||||
|  | ||||
| profile007 = startProfile(sketch002, at = [-thickness/2+1, 14]) | ||||
|   |> angledLine(angle = 150, length = 25) | ||||
|   |> angledLine(angle = 205, length = 5) | ||||
|   |> angledLine(angle = -30, endAbsoluteX = profileStartX(%)) | ||||
|   |> close(%) | ||||
|   |> extrude(%, length = 1.8) | ||||
|  | ||||
| newSketch = body001 + profile006 + profile007 | ||||
|  | ||||
| leg001Sketch = startSketchOn(newSketch, face = bottomFace) | ||||
| legProfile001 = startProfile(leg001Sketch, at = [-60, 0]) | ||||
|   |> xLine(%, length = 4) | ||||
|   |> yLine(%, length = 2) | ||||
|   |> xLine(%, endAbsolute = profileStartX(%)) | ||||
|   |> close(%) | ||||
|  | ||||
| leg001 = extrude(legProfile001, length = 48) | ||||
|   |> rotate(axis =  [0, 0, 1.0], angle = -90) | ||||
							
								
								
									
										264
									
								
								rust/kcl-lib/tests/sketch_on_face_union/ops.snap
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								rust/kcl-lib/tests/sketch_on_face_union/ops.snap
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,264 @@ | ||||
| --- | ||||
| source: kcl-lib/src/simulation_tests.rs | ||||
| description: Operations executed sketch_on_face_union.kcl | ||||
| --- | ||||
| [ | ||||
|   { | ||||
|     "labeledArgs": {}, | ||||
|     "name": "startSketchOn", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Plane", | ||||
|         "artifact_id": "[uuid]" | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": { | ||||
|       "tool": { | ||||
|         "value": { | ||||
|           "type": "Sketch", | ||||
|           "value": { | ||||
|             "artifactId": "[uuid]" | ||||
|           } | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "name": "subtract2d", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Sketch", | ||||
|         "value": { | ||||
|           "artifactId": "[uuid]" | ||||
|         } | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": { | ||||
|       "tool": { | ||||
|         "value": { | ||||
|           "type": "Sketch", | ||||
|           "value": { | ||||
|             "artifactId": "[uuid]" | ||||
|           } | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "name": "subtract2d", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Sketch", | ||||
|         "value": { | ||||
|           "artifactId": "[uuid]" | ||||
|         } | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": { | ||||
|       "length": { | ||||
|         "value": { | ||||
|           "type": "Number", | ||||
|           "value": 2.0, | ||||
|           "ty": { | ||||
|             "type": "Default", | ||||
|             "len": { | ||||
|               "type": "Inches" | ||||
|             }, | ||||
|             "angle": { | ||||
|               "type": "Degrees" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "name": "extrude", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Sketch", | ||||
|         "value": { | ||||
|           "artifactId": "[uuid]" | ||||
|         } | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": {}, | ||||
|     "name": "startSketchOn", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Plane", | ||||
|         "artifact_id": "[uuid]" | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "type": "KclStdLibCall", | ||||
|     "name": "offsetPlane", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Plane", | ||||
|         "artifact_id": "[uuid]" | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     }, | ||||
|     "labeledArgs": { | ||||
|       "offset": { | ||||
|         "value": { | ||||
|           "type": "Number", | ||||
|           "value": 0.1, | ||||
|           "ty": { | ||||
|             "type": "Default", | ||||
|             "len": { | ||||
|               "type": "Inches" | ||||
|             }, | ||||
|             "angle": { | ||||
|               "type": "Degrees" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "sourceRange": [] | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": { | ||||
|       "length": { | ||||
|         "value": { | ||||
|           "type": "Number", | ||||
|           "value": 1.8, | ||||
|           "ty": { | ||||
|             "type": "Default", | ||||
|             "len": { | ||||
|               "type": "Inches" | ||||
|             }, | ||||
|             "angle": { | ||||
|               "type": "Degrees" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "name": "extrude", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Sketch", | ||||
|         "value": { | ||||
|           "artifactId": "[uuid]" | ||||
|         } | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": { | ||||
|       "length": { | ||||
|         "value": { | ||||
|           "type": "Number", | ||||
|           "value": 1.8, | ||||
|           "ty": { | ||||
|             "type": "Default", | ||||
|             "len": { | ||||
|               "type": "Inches" | ||||
|             }, | ||||
|             "angle": { | ||||
|               "type": "Degrees" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "name": "extrude", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Sketch", | ||||
|         "value": { | ||||
|           "artifactId": "[uuid]" | ||||
|         } | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": { | ||||
|       "face": { | ||||
|         "value": { | ||||
|           "type": "TagIdentifier", | ||||
|           "value": "bottomFace", | ||||
|           "artifact_id": "[uuid]" | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "name": "startSketchOn", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Solid", | ||||
|         "value": { | ||||
|           "artifactId": "[uuid]" | ||||
|         } | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   }, | ||||
|   { | ||||
|     "labeledArgs": { | ||||
|       "length": { | ||||
|         "value": { | ||||
|           "type": "Number", | ||||
|           "value": 48.0, | ||||
|           "ty": { | ||||
|             "type": "Default", | ||||
|             "len": { | ||||
|               "type": "Inches" | ||||
|             }, | ||||
|             "angle": { | ||||
|               "type": "Degrees" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "sourceRange": [] | ||||
|       } | ||||
|     }, | ||||
|     "name": "extrude", | ||||
|     "sourceRange": [], | ||||
|     "type": "StdLibCall", | ||||
|     "unlabeledArg": { | ||||
|       "value": { | ||||
|         "type": "Sketch", | ||||
|         "value": { | ||||
|           "artifactId": "[uuid]" | ||||
|         } | ||||
|       }, | ||||
|       "sourceRange": [] | ||||
|     } | ||||
|   } | ||||
| ] | ||||
							
								
								
									
										2639
									
								
								rust/kcl-lib/tests/sketch_on_face_union/program_memory.snap
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2639
									
								
								rust/kcl-lib/tests/sketch_on_face_union/program_memory.snap
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								rust/kcl-lib/tests/sketch_on_face_union/rendered_model.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								rust/kcl-lib/tests/sketch_on_face_union/rendered_model.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 57 KiB | 
							
								
								
									
										62
									
								
								rust/kcl-lib/tests/sketch_on_face_union/unparsed.snap
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								rust/kcl-lib/tests/sketch_on_face_union/unparsed.snap
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | ||||
| --- | ||||
| source: kcl-lib/src/simulation_tests.rs | ||||
| description: Result of unparsing sketch_on_face_union.kcl | ||||
| --- | ||||
| @settings(defaultLengthUnit = in) | ||||
|  | ||||
| // Define parameters | ||||
| trussSupportAngle = 15 | ||||
| height = 120 | ||||
| thickness = 4 | ||||
|  | ||||
| sketch001 = startSketchOn(YZ) | ||||
| profile001 = startProfile(sketch001, at = [60, 0]) | ||||
|   |> xLine(length = -120, tag = $bottomFace) | ||||
|   |> yLine(length = 12) | ||||
|   |> angledLine(angle = 25, endAbsoluteX = 0, tag = $tag001) | ||||
|   |> angledLine(angle = -25, endAbsoluteX = 60) | ||||
|   |> close() | ||||
|  | ||||
| profile002 = startProfile(sketch001, at = [60 - thickness, thickness]) | ||||
|   |> xLine(endAbsolute = thickness / 2) | ||||
|   |> yLine(endAbsolute = segEndY(tag001) - thickness) // update | ||||
|   |> angledLine(endAbsoluteX = profileStartX(%), angle = -25) | ||||
|   |> close(%) | ||||
|  | ||||
| profile003 = startProfile(sketch001, at = [-60 + thickness, thickness]) | ||||
|   |> xLine(endAbsolute = -thickness / 2) | ||||
|   |> yLine(endAbsolute = segEndY(tag001) - thickness) // update | ||||
|   |> angledLine(endAbsoluteX = profileStartX(%), angle = 205) | ||||
|   |> close(%) | ||||
|  | ||||
| profile004 = subtract2d(profile001, tool = profile002) | ||||
| subtract2d(profile001, tool = profile003) | ||||
|  | ||||
| body001 = extrude(profile001, length = 2) | ||||
|  | ||||
| sketch002 = startSketchOn(offsetPlane(YZ, offset = .1)) | ||||
| profile006 = startProfile(sketch002, at = [thickness / 2 - 1, 14]) | ||||
|   |> angledLine(angle = 30, length = 25) | ||||
|   |> angledLine(angle = -25, length = 5) | ||||
|   |> angledLine(angle = 210, endAbsoluteX = profileStartX(%)) | ||||
|   |> close(%) | ||||
|   |> extrude(%, length = 1.8) | ||||
|  | ||||
| profile007 = startProfile(sketch002, at = [-thickness / 2 + 1, 14]) | ||||
|   |> angledLine(angle = 150, length = 25) | ||||
|   |> angledLine(angle = 205, length = 5) | ||||
|   |> angledLine(angle = -30, endAbsoluteX = profileStartX(%)) | ||||
|   |> close(%) | ||||
|   |> extrude(%, length = 1.8) | ||||
|  | ||||
| newSketch = body001 + profile006 + profile007 | ||||
|  | ||||
| leg001Sketch = startSketchOn(newSketch, face = bottomFace) | ||||
| legProfile001 = startProfile(leg001Sketch, at = [-60, 0]) | ||||
|   |> xLine(%, length = 4) | ||||
|   |> yLine(%, length = 2) | ||||
|   |> xLine(%, endAbsolute = profileStartX(%)) | ||||
|   |> close(%) | ||||
|  | ||||
| leg001 = extrude(legProfile001, length = 48) | ||||
|   |> rotate(axis = [0, 0, 1.0], angle = -90) | ||||
| @ -1,328 +0,0 @@ | ||||
| import { language, syntaxTree } from '@codemirror/language' | ||||
| import type { Extension, Range, Text } from '@codemirror/state' | ||||
| import type { DecorationSet, ViewUpdate } from '@codemirror/view' | ||||
| import { | ||||
|   Decoration, | ||||
|   EditorView, | ||||
|   ViewPlugin, | ||||
|   WidgetType, | ||||
| } from '@codemirror/view' | ||||
| import type { Tree } from '@lezer/common' | ||||
| import { NodeProp } from '@lezer/common' | ||||
| import { isArray } from '@src/lib/utils' | ||||
|  | ||||
| interface PickerState { | ||||
|   from: number | ||||
|   to: number | ||||
|   alpha: string | ||||
|   colorType: ColorType | ||||
| } | ||||
|  | ||||
| export interface WidgetOptions extends PickerState { | ||||
|   color: string | ||||
| } | ||||
|  | ||||
| export type ColorData = Omit<WidgetOptions, 'from' | 'to'> | ||||
|  | ||||
| const pickerState = new WeakMap<HTMLInputElement, PickerState>() | ||||
|  | ||||
| export enum ColorType { | ||||
|   hex = 'HEX', | ||||
| } | ||||
|  | ||||
| const hexRegex = /(^|\b)(#[0-9a-f]{3,9})(\b|$)/i | ||||
|  | ||||
| function discoverColorsInKCL( | ||||
|   syntaxTree: Tree, | ||||
|   from: number, | ||||
|   to: number, | ||||
|   typeName: string, | ||||
|   doc: Text, | ||||
|   language?: string | ||||
| ): WidgetOptions | Array<WidgetOptions> | null { | ||||
|   switch (typeName) { | ||||
|     case 'Program': | ||||
|     case 'VariableDeclaration': | ||||
|     case 'CallExpressionKw': | ||||
|     case 'ObjectExpression': | ||||
|     case 'ObjectProperty': | ||||
|     case 'ArgumentList': | ||||
|     case 'PipeExpression': { | ||||
|       let innerTree = syntaxTree.resolveInner(from, 0).tree | ||||
|  | ||||
|       if (!innerTree) { | ||||
|         innerTree = syntaxTree.resolveInner(from, 1).tree | ||||
|         if (!innerTree) { | ||||
|           return null | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       const overlayTree = innerTree.prop(NodeProp.mounted)?.tree | ||||
|  | ||||
|       if (overlayTree?.type.name !== 'Styles') { | ||||
|         return null | ||||
|       } | ||||
|  | ||||
|       const ret: Array<WidgetOptions> = [] | ||||
|       overlayTree.iterate({ | ||||
|         from: 0, | ||||
|         to: overlayTree.length, | ||||
|         enter: ({ type, from: overlayFrom, to: overlayTo }) => { | ||||
|           const maybeWidgetOptions = discoverColorsInKCL( | ||||
|             syntaxTree, | ||||
|             // We add one because the tree doesn't include the | ||||
|             // quotation mark from the style tag | ||||
|             from + 1 + overlayFrom, | ||||
|             from + 1 + overlayTo, | ||||
|             type.name, | ||||
|             doc, | ||||
|             language | ||||
|           ) | ||||
|  | ||||
|           if (maybeWidgetOptions) { | ||||
|             if (isArray(maybeWidgetOptions)) { | ||||
|               console.error('Unexpected nested overlays') | ||||
|               ret.push(...maybeWidgetOptions) | ||||
|             } else { | ||||
|               ret.push(maybeWidgetOptions) | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|       }) | ||||
|  | ||||
|       return ret | ||||
|     } | ||||
|  | ||||
|     case 'String': { | ||||
|       const result = parseColorLiteral(doc.sliceString(from, to)) | ||||
|       if (!result) { | ||||
|         return null | ||||
|       } | ||||
|       return { | ||||
|         ...result, | ||||
|         from, | ||||
|         to, | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     default: | ||||
|       return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function parseColorLiteral(colorLiteral: string): ColorData | null { | ||||
|   const literal = colorLiteral.replace(/"/g, '') | ||||
|   const match = hexRegex.exec(literal) | ||||
|   if (!match) { | ||||
|     return null | ||||
|   } | ||||
|   const [color, alpha] = toFullHex(literal) | ||||
|  | ||||
|   return { | ||||
|     colorType: ColorType.hex, | ||||
|     color, | ||||
|     alpha, | ||||
|   } | ||||
| } | ||||
|  | ||||
| function colorPickersDecorations( | ||||
|   view: EditorView, | ||||
|   discoverColors: typeof discoverColorsInKCL | ||||
| ) { | ||||
|   const widgets: Array<Range<Decoration>> = [] | ||||
|  | ||||
|   const st = syntaxTree(view.state) | ||||
|  | ||||
|   for (const range of view.visibleRanges) { | ||||
|     st.iterate({ | ||||
|       from: range.from, | ||||
|       to: range.to, | ||||
|       enter: ({ type, from, to }) => { | ||||
|         const maybeWidgetOptions = discoverColors( | ||||
|           st, | ||||
|           from, | ||||
|           to, | ||||
|           type.name, | ||||
|           view.state.doc, | ||||
|           view.state.facet(language)?.name | ||||
|         ) | ||||
|  | ||||
|         if (!maybeWidgetOptions) { | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         if (!isArray(maybeWidgetOptions)) { | ||||
|           widgets.push( | ||||
|             Decoration.widget({ | ||||
|               widget: new ColorPickerWidget(maybeWidgetOptions), | ||||
|               side: 1, | ||||
|             }).range(maybeWidgetOptions.from) | ||||
|           ) | ||||
|  | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         for (const wo of maybeWidgetOptions) { | ||||
|           widgets.push( | ||||
|             Decoration.widget({ | ||||
|               widget: new ColorPickerWidget(wo), | ||||
|               side: 1, | ||||
|             }).range(wo.from) | ||||
|           ) | ||||
|         } | ||||
|       }, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   return Decoration.set(widgets) | ||||
| } | ||||
|  | ||||
| function toFullHex(color: string): string[] { | ||||
|   if (color.length === 4) { | ||||
|     // 3-char hex | ||||
|     return [ | ||||
|       `#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, | ||||
|       '', | ||||
|     ] | ||||
|   } | ||||
|  | ||||
|   if (color.length === 5) { | ||||
|     // 4-char hex (alpha) | ||||
|     return [ | ||||
|       `#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, | ||||
|       color[4].repeat(2), | ||||
|     ] | ||||
|   } | ||||
|  | ||||
|   if (color.length === 9) { | ||||
|     // 8-char hex (alpha) | ||||
|     return [`#${color.slice(1, -2)}`, color.slice(-2)] | ||||
|   } | ||||
|  | ||||
|   return [color, ''] | ||||
| } | ||||
|  | ||||
| export const wrapperClassName = 'cm-css-color-picker-wrapper' | ||||
|  | ||||
| class ColorPickerWidget extends WidgetType { | ||||
|   private readonly state: PickerState | ||||
|   private readonly color: string | ||||
|  | ||||
|   constructor({ color, ...state }: WidgetOptions) { | ||||
|     super() | ||||
|     this.state = state | ||||
|     this.color = color | ||||
|   } | ||||
|  | ||||
|   eq(other: ColorPickerWidget) { | ||||
|     return ( | ||||
|       other.state.colorType === this.state.colorType && | ||||
|       other.color === this.color && | ||||
|       other.state.from === this.state.from && | ||||
|       other.state.to === this.state.to && | ||||
|       other.state.alpha === this.state.alpha | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   toDOM() { | ||||
|     const picker = document.createElement('input') | ||||
|     pickerState.set(picker, this.state) | ||||
|     picker.type = 'color' | ||||
|     picker.value = this.color | ||||
|  | ||||
|     const wrapper = document.createElement('span') | ||||
|     wrapper.appendChild(picker) | ||||
|     wrapper.className = wrapperClassName | ||||
|  | ||||
|     return wrapper | ||||
|   } | ||||
|  | ||||
|   ignoreEvent() { | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const colorPickerTheme = EditorView.baseTheme({ | ||||
|   [`.${wrapperClassName}`]: { | ||||
|     display: 'inline-block', | ||||
|     outline: '1px solid #eee', | ||||
|     marginRight: '0.6ch', | ||||
|     height: '1em', | ||||
|     width: '1em', | ||||
|     transform: 'translateY(1px)', | ||||
|   }, | ||||
|   [`.${wrapperClassName} input[type="color"]`]: { | ||||
|     cursor: 'pointer', | ||||
|     height: '100%', | ||||
|     width: '100%', | ||||
|     padding: 0, | ||||
|     border: 'none', | ||||
|     '&::-webkit-color-swatch-wrapper': { | ||||
|       padding: 0, | ||||
|     }, | ||||
|     '&::-webkit-color-swatch': { | ||||
|       border: 'none', | ||||
|     }, | ||||
|     '&::-moz-color-swatch': { | ||||
|       border: 'none', | ||||
|     }, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| interface IFactoryOptions { | ||||
|   discoverColors: typeof discoverColorsInKCL | ||||
| } | ||||
|  | ||||
| export const makeColorPicker = (options: IFactoryOptions) => | ||||
|   ViewPlugin.fromClass( | ||||
|     class ColorPickerViewPlugin { | ||||
|       decorations: DecorationSet | ||||
|  | ||||
|       constructor(view: EditorView) { | ||||
|         this.decorations = colorPickersDecorations(view, options.discoverColors) | ||||
|       } | ||||
|  | ||||
|       update(update: ViewUpdate) { | ||||
|         if (update.docChanged || update.viewportChanged) { | ||||
|           this.decorations = colorPickersDecorations( | ||||
|             update.view, | ||||
|             options.discoverColors | ||||
|           ) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       decorations: (v) => v.decorations, | ||||
|       eventHandlers: { | ||||
|         change: (e, view) => { | ||||
|           const target = e.target as HTMLInputElement | ||||
|           if ( | ||||
|             target.nodeName !== 'INPUT' || | ||||
|             !target.parentElement || | ||||
|             !target.parentElement.classList.contains(wrapperClassName) | ||||
|           ) { | ||||
|             return false | ||||
|           } | ||||
|  | ||||
|           const data = pickerState.get(target)! | ||||
|  | ||||
|           let converted = '"' + target.value + data.alpha + '"' | ||||
|  | ||||
|           view.dispatch({ | ||||
|             changes: { | ||||
|               from: data.from, | ||||
|               to: data.to, | ||||
|               insert: converted, | ||||
|             }, | ||||
|           }) | ||||
|  | ||||
|           return true | ||||
|         }, | ||||
|       }, | ||||
|     } | ||||
|   ) | ||||
|  | ||||
| export const colorPicker: Extension = [ | ||||
|   makeColorPicker({ discoverColors: discoverColorsInKCL }), | ||||
|   colorPickerTheme, | ||||
| ] | ||||
| @ -7,6 +7,7 @@ import type { | ||||
| } from '@kittycad/codemirror-lsp-client' | ||||
| import { | ||||
|   lspCodeActionEvent, | ||||
|   lspColorUpdateEvent, | ||||
|   lspFormatCodeEvent, | ||||
|   lspPlugin, | ||||
|   lspRenameEvent, | ||||
| @ -88,6 +89,8 @@ export class KclPlugin implements PluginValue { | ||||
|         isRelevant = true | ||||
|       } else if (tr.annotation(lspCodeActionEvent.type)) { | ||||
|         isRelevant = true | ||||
|       } else if (tr.annotation(lspColorUpdateEvent.type)) { | ||||
|         isRelevant = true | ||||
|       } | ||||
|  | ||||
|       // Don't make this an else. | ||||
|  | ||||
| @ -8,7 +8,6 @@ import type { | ||||
| import type * as LSP from 'vscode-languageserver-protocol' | ||||
|  | ||||
| import { kclPlugin } from '@src/editor/plugins/lsp/kcl' | ||||
| import { colorPicker } from '@src/editor/plugins/lsp/kcl/colors' | ||||
|  | ||||
| export interface LanguageOptions { | ||||
|   workspaceFolders: LSP.WorkspaceFolder[] | ||||
| @ -22,7 +21,6 @@ export interface LanguageOptions { | ||||
|  | ||||
| export function kcl(options: LanguageOptions) { | ||||
|   return new LanguageSupport(KclLanguage, [ | ||||
|     colorPicker, | ||||
|     kclPlugin({ | ||||
|       documentUri: options.documentUri, | ||||
|       workspaceFolders: options.workspaceFolders, | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	