diff --git a/app/gui2/parser-codegen/codegen.ts b/app/gui2/parser-codegen/codegen.ts index 8a7b0f432233..bd0d15af022d 100644 --- a/app/gui2/parser-codegen/codegen.ts +++ b/app/gui2/parser-codegen/codegen.ts @@ -14,7 +14,6 @@ import { abstractTypeDeserializer, abstractTypeVariants, fieldDeserializer, - fieldDynValue, fieldVisitor, seekViewDyn, support, @@ -246,53 +245,6 @@ function makeReadFunction( ) } -function makeDebugFunction(fields: Field[], typeName?: string): ts.MethodDeclaration { - const ident = tsf.createIdentifier('fields') - const fieldAssignments = fields.map((field) => - tsf.createArrayLiteralExpression([ - tsf.createStringLiteral(field.name), - fieldDynValue(field.type, field.offset), - ]), - ) - if (typeName != null) { - fieldAssignments.push( - tsf.createArrayLiteralExpression([ - tsf.createStringLiteral('type'), - tsf.createObjectLiteralExpression([ - tsf.createPropertyAssignment('type', tsf.createStringLiteral('primitive')), - tsf.createPropertyAssignment('value', tsf.createStringLiteral(typeName)), - ]), - ]), - ) - } - return tsf.createMethodDeclaration( - [], - undefined, - ident, - undefined, - [], - [], - tsf.createTypeReferenceNode(`[string, ${support.DynValue}][]`), - tsf.createBlock([ - tsf.createReturnStatement( - tsf.createArrayLiteralExpression( - [ - tsf.createSpreadElement( - tsf.createCallExpression( - tsf.createPropertyAccessExpression(tsf.createSuper(), ident), - undefined, - undefined, - ), - ), - ...fieldAssignments, - ], - true, - ), - ), - ]), - ) -} - function makeVisitFunction(fields: Field[]): ts.MethodDeclaration { const ident = tsf.createIdentifier('visitChildren') const visitorParam = tsf.createIdentifier('visitor') @@ -347,7 +299,7 @@ function makeVisitFunction(fields: Field[]): ts.MethodDeclaration { ) } -function makeGetters(id: string, schema: Schema.Schema, typeName?: string): ts.ClassElement[] { +function makeGetters(id: string, schema: Schema.Schema): ts.ClassElement[] { const serialization = schema.serialization[id] const type = schema.types[id] if (serialization == null || type == null) throw new Error(`Invalid type id: ${id}`) @@ -360,7 +312,6 @@ function makeGetters(id: string, schema: Schema.Schema, typeName?: string): ts.C ...fields.map(makeGetter), ...fields.map(makeElementVisitor).filter((v): v is ts.ClassElement => v != null), makeVisitFunction(fields), - makeDebugFunction(fields, typeName), ] } @@ -394,7 +345,6 @@ type ChildType = { definition: ts.ClassDeclaration name: ts.Identifier enumMember: ts.EnumMember - case: ts.CaseClause } function makeChildType( @@ -457,12 +407,11 @@ function makeChildType( viewIdent, tsf.createNewExpression(ident, [], [seekViewDyn(viewIdent, addressIdent)]), ), - ...makeGetters(id, schema, name), + ...makeGetters(id, schema), ], ), name: tsf.createIdentifier(name), - enumMember: tsf.createEnumMember(toPascal(ty.name), discriminantInt), - case: tsf.createCaseClause(discriminantInt, [tsf.createReturnStatement(viewIdent)]), + enumMember: tsf.createEnumMember(name, discriminantInt), } } @@ -501,6 +450,12 @@ function makeAbstractType( 'Type', childTypes.map((child) => child.enumMember), ), + makeExportConstVariable( + 'typeNames', + tsf.createArrayLiteralExpression( + childTypes.map((child) => tsf.createStringLiteralFromNode(child.name)), + ), + ), ...childTypes.map((child) => child.definition), tsf.createTypeAliasDeclaration( [modifiers.export], @@ -522,11 +477,31 @@ function makeAbstractType( [modifiers.export], ident, undefined, - tsf.createTypeReferenceNode(tsf.createQualifiedName(tsf.createIdentifier(name), name)), + tsf.createTypeReferenceNode(tsf.createQualifiedName(ident, ident)), ) return { module: moduleDecl, export: abstractTypeExport } } +function makeExportConstVariable( + varName: string, + initializer: ts.Expression, +): ts.VariableStatement { + return tsf.createVariableStatement( + [modifiers.export], + tsf.createVariableDeclarationList( + [ + tsf.createVariableDeclaration( + varName, + undefined, + undefined, + tsf.createAsExpression(initializer, tsf.createTypeReferenceNode('const')), + ), + ], + ts.NodeFlags.Const, + ), + ) +} + function makeIsInstance(type: ts.TypeNode, baseIdent: ts.Identifier): ts.FunctionDeclaration { const param = tsf.createIdentifier('obj') const paramDecl = tsf.createParameterDeclaration( diff --git a/app/gui2/parser-codegen/serialization.ts b/app/gui2/parser-codegen/serialization.ts index 5bdebb3d0c4b..a544597bb5e7 100644 --- a/app/gui2/parser-codegen/serialization.ts +++ b/app/gui2/parser-codegen/serialization.ts @@ -17,8 +17,6 @@ export const supportImports = { ObjectVisitor: true, ObjectAddressVisitor: true, Result: true, - DynValue: true, - Dyn: false, readU8: false, readU32: false, readI32: false, @@ -43,8 +41,6 @@ export const support = { DataView: tsf.createTypeReferenceNode(tsf.createIdentifier('DataView')), Result: (t0: ts.TypeNode, t1: ts.TypeNode) => tsf.createTypeReferenceNode(tsf.createIdentifier('Result'), [t0, t1]), - DynValue: 'DynValue', - Dyn: tsf.createIdentifier('Dyn'), readU8: tsf.createIdentifier('readU8'), readU32: tsf.createIdentifier('readU32'), readI32: tsf.createIdentifier('readI32'), @@ -75,13 +71,6 @@ const baseReaders = { readOption: readerTransformer(support.readOption), readResult: readerTransformerTwoTyped(support.readResult), } as const -const dynBuilders = { - Primitive: dynReader('Primitive'), - Result: dynReader('Result'), - Sequence: dynReader('Sequence'), - Option: dynReader('Option'), - Object: dynReader('Object'), -} as const type ReadApplicator = (cursor: ts.Expression, offset: AccessOffset) => ts.Expression type VisitorApplicator = (cursor: ts.Expression, offset: AccessOffset) => ts.Expression @@ -91,51 +80,35 @@ type VisitorApplicator = (cursor: ts.Expression, offset: AccessOffset) => ts.Exp export class Type { readonly type: ts.TypeNode readonly reader: ReadApplicator - readonly dynReader: ReadApplicator readonly visitor: VisitorApplicator | undefined | 'visitValue' readonly size: number private constructor( type: ts.TypeNode, reader: ReadApplicator, - dynReader: ReadApplicator, visitor: VisitorApplicator | undefined | 'visitValue', size: number, ) { this.type = type this.reader = reader - this.dynReader = dynReader this.visitor = visitor this.size = size } static Abstract(name: string): Type { const valueReader = callRead(name) - return new Type( - tsf.createTypeReferenceNode(name), - valueReader, - dynBuilders.Object(valueReader), - 'visitValue', - POINTER_SIZE, - ) + return new Type(tsf.createTypeReferenceNode(name), valueReader, 'visitValue', POINTER_SIZE) } static Concrete(name: string, size: number): Type { const valueReader = callRead(name) - return new Type( - tsf.createTypeReferenceNode(name), - valueReader, - dynBuilders.Object(valueReader), - 'visitValue', - size, - ) + return new Type(tsf.createTypeReferenceNode(name), valueReader, 'visitValue', size) } static Sequence(element: Type): Type { return new Type( tsf.createTypeReferenceNode('Iterable', [element.type]), createSequenceReader(element.size, element.reader), - dynBuilders.Sequence(createSequenceReader(element.size, element.dynReader)), createSequenceVisitor(element.size, visitorClosure(element.visitor, element.reader)), POINTER_SIZE, ) @@ -145,7 +118,6 @@ export class Type { return new Type( tsf.createUnionTypeNode([element.type, noneType]), baseReaders.readOption(element.reader), - dynBuilders.Option(baseReaders.readOption(element.dynReader)), createOptionVisitor(visitorClosure(element.visitor, element.reader)), POINTER_SIZE + 1, ) @@ -155,7 +127,6 @@ export class Type { return new Type( support.Result(ok.type, err.type), baseReaders.readResult(ok.reader, err.reader), - dynBuilders.Result(baseReaders.readResult(ok.dynReader, err.dynReader)), createResultVisitor( visitorClosure(ok.visitor, ok.reader), visitorClosure(err.visitor, err.reader), @@ -167,49 +138,42 @@ export class Type { static Boolean: Type = new Type( tsf.createTypeReferenceNode('boolean'), baseReaders.readBool, - dynBuilders.Primitive(baseReaders.readBool), undefined, 1, ) static UInt32: Type = new Type( tsf.createTypeReferenceNode('number'), baseReaders.readU32, - dynBuilders.Primitive(baseReaders.readU32), undefined, 4, ) static Int32: Type = new Type( tsf.createTypeReferenceNode('number'), baseReaders.readI32, - dynBuilders.Primitive(baseReaders.readI32), undefined, 4, ) static UInt64: Type = new Type( tsf.createTypeReferenceNode('bigint'), baseReaders.readU64, - dynBuilders.Primitive(baseReaders.readU64), undefined, 8, ) static Int64: Type = new Type( tsf.createTypeReferenceNode('bigint'), baseReaders.readI64, - dynBuilders.Primitive(baseReaders.readI64), undefined, 8, ) static Char: Type = new Type( tsf.createTypeReferenceNode('number'), baseReaders.readU32, - dynBuilders.Primitive(baseReaders.readU32), undefined, 4, ) static String: Type = new Type( tsf.createTypeReferenceNode('string'), baseReaders.readString, - dynBuilders.Primitive(baseReaders.readString), undefined, POINTER_SIZE, ) @@ -303,10 +267,6 @@ export function fieldVisitor( ) } -export function fieldDynValue(type: Type, address: number): ts.Expression { - return type.dynReader(thisAccess(viewFieldIdent), makeConstantAddress(address)) -} - function thisAccess(ident: ts.Identifier): ts.PropertyAccessExpression { return tsf.createPropertyAccessExpression(tsf.createThis(), ident) } @@ -376,16 +336,6 @@ function readerTransformerTwoTyped( } } -function dynReader(name: string): (readValue: ReadApplicator) => ReadApplicator { - return (readValue) => (view, address) => { - return tsf.createCallExpression( - tsf.createPropertyAccessExpression(support.Dyn, name), - [], - [readValue(view, address)], - ) - } -} - export function callRead(ident: string): ReadApplicator { return (view, address) => tsf.createCallExpression( diff --git a/app/gui2/public/visualizations/ScatterplotVisualization.vue b/app/gui2/public/visualizations/ScatterplotVisualization.vue index 457711f51efb..3e45ad6d2f10 100644 --- a/app/gui2/public/visualizations/ScatterplotVisualization.vue +++ b/app/gui2/public/visualizations/ScatterplotVisualization.vue @@ -208,15 +208,15 @@ const margin = computed(() => { } }) const width = computed(() => - config.value.fullscreen + config.fullscreen ? containerNode.value?.parentElement?.clientWidth ?? 0 - : Math.max(config.value.width ?? 0, config.value.nodeSize.x), + : Math.max(config.width ?? 0, config.nodeSize.x), ) const height = computed(() => - config.value.fullscreen + config.fullscreen ? containerNode.value?.parentElement?.clientHeight ?? 0 - : config.value.height ?? (config.value.nodeSize.x * 3) / 4, + : config.height ?? (config.nodeSize.x * 3) / 4, ) const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right)) diff --git a/app/gui2/shared/yjsModel.ts b/app/gui2/shared/yjsModel.ts index d97134ebc3b9..658facaadd59 100644 --- a/app/gui2/shared/yjsModel.ts +++ b/app/gui2/shared/yjsModel.ts @@ -164,7 +164,10 @@ export class DistributedModule { const start = range == null ? exprStart : exprStart + range[0] const end = range == null ? exprEnd : exprStart + range[1] if (start > end) throw new Error('Invalid range') - if (start < exprStart || end > exprEnd) throw new Error('Range out of bounds') + if (start < exprStart || end > exprEnd) + throw new Error( + `Range out of bounds. Got [${start}, ${end}], bounds are [${exprStart}, ${exprEnd}]`, + ) this.transact(() => { if (content.length > 0) { this.doc.contents.insert(start, content) @@ -230,7 +233,7 @@ export class IdMap { this.finished = false } - private static keyForRange(range: [number, number]): string { + private static keyForRange(range: readonly [number, number]): string { return `${range[0].toString(16)}:${range[1].toString(16)}` } @@ -256,7 +259,12 @@ export class IdMap { this.accessed.add(id) } - getOrInsertUniqueId(range: [number, number]): ExprId { + getIfExist(range: readonly [number, number]): ExprId | undefined { + const key = IdMap.keyForRange(range) + return this.rangeToExpr.get(key) + } + + getOrInsertUniqueId(range: readonly [number, number]): ExprId { if (this.finished) { throw new Error('IdMap already finished') } @@ -292,7 +300,7 @@ export class IdMap { * * Can be called at most once. After calling this method, the ID map is no longer usable. */ - finishAndSynchronize(): void { + finishAndSynchronize(): typeof this.yMap { if (this.finished) { throw new Error('IdMap already finished') } @@ -320,6 +328,7 @@ export class IdMap { this.yMap.set(expr, encoded) }) }) + return this.yMap } } diff --git a/app/gui2/src/App.vue b/app/gui2/src/App.vue index 4109ad176f32..755d64970c81 100644 --- a/app/gui2/src/App.vue +++ b/app/gui2/src/App.vue @@ -15,11 +15,22 @@ onMounted(() => useSuggestionDbStore()) - + diff --git a/app/gui2/src/assets/base.css b/app/gui2/src/assets/base.css index 923245122e17..6dd564a3ce02 100644 --- a/app/gui2/src/assets/base.css +++ b/app/gui2/src/assets/base.css @@ -68,23 +68,6 @@ margin: 0; } -body { - min-height: 100vh; - color: var(--color-text); - /* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */ - background: #e4d4be; - transition: - color 0.5s, - background-color 0.5s; - font-family: 'M PLUS 1', sans-serif; - font-size: 11.5px; - font-weight: 500; - line-height: 20px; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - .icon { width: 16px; height: 16px; diff --git a/app/gui2/src/bindings.ts b/app/gui2/src/bindings.ts index c9b971d8d120..da6245b3690d 100644 --- a/app/gui2/src/bindings.ts +++ b/app/gui2/src/bindings.ts @@ -18,18 +18,18 @@ export const graphBindings = defineKeybinds('graph-editor', { dragScene: ['PointerAux', 'Mod+PointerMain'], openComponentBrowser: ['Enter'], newNode: ['N'], -}) - -export const nodeSelectionBindings = defineKeybinds('node-selection', { + toggleVisualization: ['Space'], deleteSelected: ['Delete'], selectAll: ['Mod+A'], deselectAll: ['Escape', 'PointerMain'], +}) + +export const selectionMouseBindings = defineKeybinds('selection', { replace: ['PointerMain'], add: ['Mod+Shift+PointerMain'], remove: ['Shift+Alt+PointerMain'], toggle: ['Shift+PointerMain'], invert: ['Mod+Shift+Alt+PointerMain'], - toggleVisualization: ['Space'], }) export const nodeEditBindings = defineKeybinds('node-edit', { diff --git a/app/gui2/src/components/CodeEditor.vue b/app/gui2/src/components/CodeEditor.vue index 22871453f98b..3ba35fcefeb7 100644 --- a/app/gui2/src/components/CodeEditor.vue +++ b/app/gui2/src/components/CodeEditor.vue @@ -6,8 +6,8 @@ import type { Highlighter } from '@/util/codemirror' import { colorFromString } from '@/util/colors' import { usePointer } from '@/util/events' import { useLocalStorage } from '@vueuse/core' +import { rangeEncloses } from 'shared/yjsModel' import { computed, onMounted, ref, watchEffect } from 'vue' -import * as Y from 'yjs' import { qnJoin, tryQualifiedName } from '../util/qualifiedName' import { unwrap } from '../util/result' @@ -52,34 +52,34 @@ watchEffect(() => { foldGutter(), highlightSelectionMatches(), tooltips({ position: 'absolute' }), - hoverTooltip((ast) => { + hoverTooltip((ast, syn) => { const dom = document.createElement('div') - const ydoc = projectStore.module?.doc.ydoc - if (ydoc == null) return - const start = ast.whitespaceStartInCodeParsed + ast.whitespaceLengthInCodeParsed - const end = start + ast.childrenLengthInCodeParsed + const astSpan = ast.span() let foundNode: Node | undefined for (const node of graphStore.nodes.values()) { - const nodeStart = Y.createAbsolutePositionFromRelativePosition(node.docRange[0], ydoc) - ?.index - if (nodeStart == null || nodeStart > start) continue - const nodeEnd = Y.createAbsolutePositionFromRelativePosition(node.docRange[1], ydoc) - ?.index - if (nodeEnd == null || nodeEnd < end) continue - foundNode = node - break + if (rangeEncloses(node.rootSpan.span(), astSpan)) { + foundNode = node + break + } } - if (foundNode == null) return - const expressionInfo = projectStore.computedValueRegistry.getExpressionInfo( - foundNode.rootSpan.id, - ) - if (expressionInfo == null) return - dom - .appendChild(document.createElement('div')) - .appendChild(document.createTextNode(`AST ID: ${foundNode.rootSpan.id}`)) + const expressionInfo = foundNode + ? projectStore.computedValueRegistry.getExpressionInfo(foundNode.rootSpan.astId) + : undefined + + if (foundNode != null) { + dom + .appendChild(document.createElement('div')) + .appendChild(document.createTextNode(`AST ID: ${foundNode.rootSpan.astId}`)) + } + if (expressionInfo != null) { + dom + .appendChild(document.createElement('div')) + .appendChild(document.createTextNode(`Type: ${expressionInfo.typename ?? 'Unknown'}`)) + } + dom .appendChild(document.createElement('div')) - .appendChild(document.createTextNode(`Type: ${expressionInfo.typename ?? 'Unknown'}`)) + .appendChild(document.createTextNode(`Syntax: ${syn.toString()}`)) const method = expressionInfo?.methodCall?.methodPointer if (method != null) { const moduleName = tryQualifiedName(method.module) @@ -176,7 +176,8 @@ const editorStyle = computed(() => { position: absolute; width: 100%; height: 100%; - backdrop-filter: blur(16px); + backdrop-filter: var(--blur-app-bg); + border-radius: 7px; } &.v-enter-active, @@ -246,6 +247,7 @@ const editorStyle = computed(() => { content: ''; background-color: rgba(255, 255, 255, 0.35); backdrop-filter: blur(64px); + border-radius: 4px; } } diff --git a/app/gui2/src/components/ComponentBrowser/input.ts b/app/gui2/src/components/ComponentBrowser/input.ts index 71b70f940d29..18966411b003 100644 --- a/app/gui2/src/components/ComponentBrowser/input.ts +++ b/app/gui2/src/components/ComponentBrowser/input.ts @@ -1,6 +1,13 @@ import type { Filter } from '@/components/ComponentBrowser/filtering' import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry' -import { Ast, astContainingChar, astSpan, parseEnso, readAstSpan, readTokenSpan } from '@/util/ast' +import { + Ast, + astContainingChar, + parseEnso, + parsedTreeRange, + readAstSpan, + readTokenSpan, +} from '@/util/ast' import { GeneralOprApp } from '@/util/ast/opr' import { qnLastSegment, @@ -135,7 +142,7 @@ export class Input { private static pathAsQualifiedName(accessOpr: GeneralOprApp, code: string): QualifiedName | null { const operandsAsIdents = Input.qnIdentifiers(accessOpr, code) - const segments = operandsAsIdents.map((ident) => readAstSpan(ident!, code)) + const segments = operandsAsIdents.map((ident) => readAstSpan(ident, code)) const rawQn = segments.join('.') const qn = tryQualifiedName(rawQn) return qn.ok ? qn.value : null @@ -162,10 +169,10 @@ export class Input { const changes = Array.from(this.inputChangesAfterApplying(entry)).reverse() const newCodeUpToLastChange = changes.reduce( (builder, change) => { - const oldCodeFragment = oldCode.substring(builder.oldCodeIndex, change.start) + const oldCodeFragment = oldCode.substring(builder.oldCodeIndex, change.range[0]) return { code: builder.code + oldCodeFragment + change.str, - oldCodeIndex: change.end, + oldCodeIndex: change.range[1], } }, { code: '', oldCodeIndex: 0 }, @@ -190,20 +197,20 @@ export class Input { */ private *inputChangesAfterApplying( entry: SuggestionEntry, - ): Generator<{ start: number; end: number; str: string }> { + ): Generator<{ range: [number, number]; str: string }> { const ctx = this.context.value const str = this.codeToBeInserted(entry) switch (ctx.type) { case 'insert': { - yield { start: ctx.position, end: ctx.position, str } + yield { range: [ctx.position, ctx.position], str } break } case 'changeIdentifier': { - yield { ...astSpan(ctx.identifier), str } + yield { range: parsedTreeRange(ctx.identifier), str } break } case 'changeLiteral': { - yield { ...astSpan(ctx.literal), str } + yield { range: parsedTreeRange(ctx.literal), str } break } } @@ -240,7 +247,7 @@ export class Input { * See `inputChangesAfterApplying`. */ private *qnChangesAfterApplying( entry: SuggestionEntry, - ): Generator<{ start: number; end: number; str: string }> { + ): Generator<{ range: [number, number]; str: string }> { if (entry.selfType != null) return [] if (entry.kind === SuggestionKind.Local || entry.kind === SuggestionKind.Function) return [] if (this.context.value.type === 'changeLiteral') return [] @@ -254,7 +261,7 @@ export class Input { for (const ident of writtenQn) { if (containingQn == null) break const [parent, segment] = qnSplit(containingQn) - yield { ...astSpan(ident), str: segment } + yield { range: parsedTreeRange(ident), str: segment } containingQn = parent } } diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 852ebcfcbde0..a2eb5eb6612b 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -1,148 +1,50 @@ - - + - + - + +import GraphEdge from '@/components/GraphEditor/GraphEdge.vue' +import { useGraphStore } from '@/stores/graph' +const graphStore = useGraphStore() + + + + + diff --git a/app/gui2/src/components/GraphNode.vue b/app/gui2/src/components/GraphEditor/GraphNode.vue similarity index 89% rename from app/gui2/src/components/GraphNode.vue rename to app/gui2/src/components/GraphEditor/GraphNode.vue index eb4930c0f933..37017d561433 100644 --- a/app/gui2/src/components/GraphNode.vue +++ b/app/gui2/src/components/GraphEditor/GraphNode.vue @@ -1,13 +1,11 @@ @@ -469,7 +445,7 @@ watchEffect(() => { }" :class="{ dragging: dragPointer.dragging, - selected, + selected: nodeSelection?.isSelected(nodeId), visualizationVisible: isVisualizationVisible, ['executionState-' + executionState]: true, }" @@ -479,9 +455,9 @@ watchEffect(() => { {{ node.binding }} @@ -493,7 +469,7 @@ watchEffect(() => { /> - { @keydown="editableKeydownHandler" @pointerdown.stop @blur="projectStore.stopCapturingUndo()" - > - - + /> {{ outputTypeName }} diff --git a/app/gui2/src/components/GraphEditor/GraphNodes.vue b/app/gui2/src/components/GraphEditor/GraphNodes.vue new file mode 100644 index 000000000000..d73652d93f93 --- /dev/null +++ b/app/gui2/src/components/GraphEditor/GraphNodes.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/app/gui2/src/components/GraphEditor/NodeToken.vue b/app/gui2/src/components/GraphEditor/NodeToken.vue new file mode 100644 index 000000000000..94732020a712 --- /dev/null +++ b/app/gui2/src/components/GraphEditor/NodeToken.vue @@ -0,0 +1,64 @@ + + + + {{ whitespace }}{{ props.ast.repr() }} + + + diff --git a/app/gui2/src/components/GraphEditor/NodeTree.vue b/app/gui2/src/components/GraphEditor/NodeTree.vue new file mode 100644 index 000000000000..065a314e50f5 --- /dev/null +++ b/app/gui2/src/components/GraphEditor/NodeTree.vue @@ -0,0 +1,104 @@ + + + + emit('updateExprRect', id, rect)" + /> + {{ whitespace + }} + emit('updateExprRect', id, rect)" + /> + emit('updateExprRect', id, rect)" + /> + + + + + diff --git a/app/gui2/src/components/NavBar.vue b/app/gui2/src/components/NavBar.vue index 5359cc2c311d..7bb929c995cc 100644 --- a/app/gui2/src/components/NavBar.vue +++ b/app/gui2/src/components/NavBar.vue @@ -28,6 +28,7 @@ const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: numbe display: flex; border-radius: var(--radius-full); background: var(--color-frame-bg); + backdrop-filter: var(--blur-app-bg); place-items: center; gap: 12px; padding-left: 8px; diff --git a/app/gui2/src/components/NodeSpan.vue b/app/gui2/src/components/NodeSpan.vue deleted file mode 100644 index a37f53c07dd3..000000000000 --- a/app/gui2/src/components/NodeSpan.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - emit('updateExprRect', id, rect)" />{{ exprPart }} - - - diff --git a/app/gui2/src/components/ProjectTitle.vue b/app/gui2/src/components/ProjectTitle.vue index 77a5ad2b18a5..9be2ebad99ae 100644 --- a/app/gui2/src/components/ProjectTitle.vue +++ b/app/gui2/src/components/ProjectTitle.vue @@ -24,6 +24,7 @@ const emit = defineEmits<{ execute: []; 'update:mode': [mode: string] }>() align-items: center; background: var(--color-frame-bg); border-radius: var(--radius-full); + backdrop-filter: var(--blur-app-bg); gap: 8px; padding-left: 10px; padding-right: 4px; diff --git a/app/gui2/src/components/TopBar.vue b/app/gui2/src/components/TopBar.vue index 60546d446641..5eeaa1fee53c 100644 --- a/app/gui2/src/components/TopBar.vue +++ b/app/gui2/src/components/TopBar.vue @@ -1,7 +1,7 @@ diff --git a/app/gui2/src/components/visualizations/HeatmapVisualization.vue b/app/gui2/src/components/visualizations/HeatmapVisualization.vue index 8e57e6470f9d..716c5b9ae3ba 100644 --- a/app/gui2/src/components/visualizations/HeatmapVisualization.vue +++ b/app/gui2/src/components/visualizations/HeatmapVisualization.vue @@ -45,7 +45,7 @@ import { computed, ref, watchPostEffect } from 'vue' import * as d3 from 'd3' import VisualizationContainer from '@/components/VisualizationContainer.vue' -import { useVisualizationConfig } from '@/providers/visualizationConfig.ts' +import { useVisualizationConfig } from '@/providers/visualizationConfig' const props = defineProps<{ data: Data }>() @@ -88,17 +88,17 @@ const fill = computed(() => .domain([0, d3.max(buckets.value, (d) => d.value) ?? 1]), ) -const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x)) +const width = ref(Math.max(config.width ?? 0, config.nodeSize.x)) watchPostEffect(() => { - width.value = config.value.fullscreen + width.value = config.fullscreen ? containerNode.value?.parentElement?.clientWidth ?? 0 - : Math.max(config.value.width ?? 0, config.value.nodeSize.x) + : Math.max(config.width ?? 0, config.nodeSize.x) }) -const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4) +const height = ref(config.height ?? (config.nodeSize.x * 3) / 4) watchPostEffect(() => { - height.value = config.value.fullscreen + height.value = config.fullscreen ? containerNode.value?.parentElement?.clientHeight ?? 0 - : config.value.height ?? (config.value.nodeSize.x * 3) / 4 + : config.height ?? (config.nodeSize.x * 3) / 4 }) const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right)) const boxHeight = computed(() => Math.max(0, height.value - MARGIN.top - MARGIN.bottom)) diff --git a/app/gui2/src/components/visualizations/HistogramVisualization.vue b/app/gui2/src/components/visualizations/HistogramVisualization.vue index 00f9c16f968c..82170ff1f0ea 100644 --- a/app/gui2/src/components/visualizations/HistogramVisualization.vue +++ b/app/gui2/src/components/visualizations/HistogramVisualization.vue @@ -251,17 +251,17 @@ const margin = computed(() => ({ bottom: MARGIN + (axis.value?.x?.label ? AXIS_LABEL_HEIGHT : 0), left: MARGIN + (axis.value?.y?.label ? AXIS_LABEL_HEIGHT : 0), })) -const width = ref(Math.max(config.value.width ?? 0, config.value.nodeSize.x)) +const width = ref(Math.max(config.width ?? 0, config.nodeSize.x)) watchPostEffect(() => { - width.value = config.value.fullscreen + width.value = config.fullscreen ? containerNode.value?.parentElement?.clientWidth ?? 0 - : Math.max(config.value.width ?? 0, config.value.nodeSize.x) + : Math.max(config.width ?? 0, config.nodeSize.x) }) -const height = ref(config.value.height ?? (config.value.nodeSize.x * 3) / 4) +const height = ref(config.height ?? (config.nodeSize.x * 3) / 4) watchPostEffect(() => { - height.value = config.value.fullscreen + height.value = config.fullscreen ? containerNode.value?.parentElement?.clientHeight ?? 0 - : config.value.height ?? (config.value.nodeSize.x * 3) / 4 + : config.height ?? (config.nodeSize.x * 3) / 4 }) const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right)) const boxHeight = computed(() => Math.max(0, height.value - margin.value.top - margin.value.bottom)) diff --git a/app/gui2/src/components/visualizations/TableVisualization.vue b/app/gui2/src/components/visualizations/TableVisualization.vue index 768caf2af895..7d9262294802 100644 --- a/app/gui2/src/components/visualizations/TableVisualization.vue +++ b/app/gui2/src/components/visualizations/TableVisualization.vue @@ -77,7 +77,7 @@ import type { } from 'ag-grid-community' import VisualizationContainer from '@/components/VisualizationContainer.vue' -import { useVisualizationConfig } from '@/providers/visualizationConfig.ts' +import { useVisualizationConfig } from '@/providers/visualizationConfig' const props = defineProps<{ data: Data }>() const emit = defineEmits<{ @@ -394,7 +394,7 @@ onMounted(() => { }) watch( - () => config.value.fullscreen, + () => config.fullscreen, () => queueMicrotask(() => updateTableSize(undefined)), ) @@ -405,10 +405,8 @@ const debouncedUpdateTableSize = useDebounceFn((...args: Parameters [props.data, config.value.width], - () => { - debouncedUpdateTableSize(undefined) - }, + () => [props.data, config.width], + () => debouncedUpdateTableSize(undefined), ) diff --git a/app/gui2/src/providers/graphNavigator.ts b/app/gui2/src/providers/graphNavigator.ts new file mode 100644 index 000000000000..d9a5e7c2d428 --- /dev/null +++ b/app/gui2/src/providers/graphNavigator.ts @@ -0,0 +1,6 @@ +import { useNavigator } from '@/util/navigator' +import { createContextStore } from '.' + +export type GraphNavigator = ReturnType +export { injectFn as injectGraphNavigator, provideFn as provideGraphNavigator } +const { provideFn, injectFn } = createContextStore('graph navigator', useNavigator) diff --git a/app/gui2/src/providers/graphSelection.ts b/app/gui2/src/providers/graphSelection.ts new file mode 100644 index 000000000000..7427b81793d7 --- /dev/null +++ b/app/gui2/src/providers/graphSelection.ts @@ -0,0 +1,23 @@ +import type { NavigatorComposable } from '@/util/navigator' +import type { Rect } from '@/util/rect' +import { useSelection } from '@/util/selection' +import type { ExprId } from 'shared/yjsModel' +import { createContextStore } from '.' + +const SELECTION_BRUSH_MARGIN_PX = 6 + +export type GraphSelection = ReturnType +export { injectFn as injectGraphSelection, provideFn as provideGraphSelection } +const { provideFn, injectFn } = createContextStore( + 'graph selection', + ( + navigator: NavigatorComposable, + nodeRects: Map, + callbacks: { + onSelected?: (id: ExprId) => void + onDeselected?: (id: ExprId) => void + } = {}, + ) => { + return useSelection(navigator, nodeRects, SELECTION_BRUSH_MARGIN_PX, callbacks) + }, +) diff --git a/app/gui2/src/providers/guiConfig.ts b/app/gui2/src/providers/guiConfig.ts index 80d5fa928ea4..5ecf2765bb8a 100644 --- a/app/gui2/src/providers/guiConfig.ts +++ b/app/gui2/src/providers/guiConfig.ts @@ -1,4 +1,6 @@ -import { inject, provide, type InjectionKey, type Ref } from 'vue' +import { identity } from '@vueuse/core' +import { type Ref } from 'vue' +import { createContextStore } from '.' export interface GuiConfig { engine?: { @@ -14,15 +16,5 @@ export interface GuiConfig { } window?: { topBarOffset?: string } } - -const provideKey = Symbol('appConfig') as InjectionKey> - -export function useGuiConfig(): Ref { - const injected = inject(provideKey) - if (injected == null) throw new Error('AppConfig not provided') - return injected -} - -export function provideGuiConfig(appConfig: Ref) { - provide(provideKey, appConfig) -} +export { injectFn as injectGuiConfig, provideFn as provideGuiConfig } +const { provideFn, injectFn } = createContextStore('GUI config', identity>) diff --git a/app/gui2/src/providers/index.ts b/app/gui2/src/providers/index.ts new file mode 100644 index 000000000000..c8696fada2b3 --- /dev/null +++ b/app/gui2/src/providers/index.ts @@ -0,0 +1,82 @@ +import { inject, provide, type InjectionKey } from 'vue' + +const MISSING = Symbol('MISSING') + +/** + * Create a pair of functions that allow to provide and inject a store in the current component's + * context. The store is created on demand using the provided factory function. Once a store is + * provided by a component, all its descendants will be able to access it using the corresponding + * inject function.The store instance is **NOT a singleton**. Each component that provides + * the store will create its own instance, and each component that injects the store will receive + * the instance from the closest ancestor component that provided it. + * + * When creating a store, you usually want to reexport the `provideFn` and `injectFn` as renamed + * functions to make it easier to use the store in components without any name collisions. + * ```ts + * export { injectFn as injectSpecificThing, provideFn as provideSpecificThing } + * const { provideFn, injectFn } = createContextStore('specific thing', thatThingFactory) + * ``` + * + * Under the hood, this uses Vue's [Context API], therefore it can only be used within a component's + * setup function. + * + * @param name A user-friendly name for the store, used for error messages and debugging. The name + * has no influence on the standard runtime behavior of the store, and doesn't have to be unique. + * @param factory A factory function that creates the store. The parameters expected by the factory + * will match the parameters of the generated `provideFn` function. + * @returns A pair of functions, `provideFn` and `injectFn`. The `provideFn` function creates the + * store instance and provides it to the context, allowing child components to access it. The + * `injectFn` function retrieves the store instance provided by any of the an ancestor components. + * + * [Context API]: https://vuejs.org/guide/components/provide-inject.html#provide-inject + */ +export function createContextStore any>(name: string, factory: F) { + const provideKey = Symbol(name) as InjectionKey> + + /** + * Create the instance of a store and store it in the current component's context. All child + * components will be able to access that store using the corresponding inject function. + * + * @param args The parameters that will be passed to the store factory function. + * @returns The store instance created by the factory function. + */ + function provideFn(...args: Parameters): ReturnType { + const constructed = factory(...args) + provide(provideKey, constructed) + return constructed + } + + /** + * Access a store instance provided by an ancestor component. When trying to access a store that + * has never been provided, the behavior depends on the first parameter value. + * + * @param missingBehavior determines what happens when trying to inject a store has never been provided: + * - If `missingBehavior` is `false` or it is not provided, an exception is thrown. + * - If `missingBehavior` is `true`, `undefined` is returned. This is also reflected in the return + * type of the function. + * - If `missingBehavior` is a closure returning store factory arguments, a new store instance + * is created and provided to this component's context, allowing its children to access it. + * @returns The store instance provided by an ancestor component, or `undefined` if the store + * has never been provided and `missingBehavior` is `true`. + */ + + function injectFn(allowMissing: true): ReturnType | undefined + function injectFn(allowMissing?: false): ReturnType + function injectFn(orProvideWith: () => Parameters): ReturnType + function injectFn( + missingBehavior: boolean | (() => Parameters) = false, + ): ReturnType | undefined { + const injected = inject | typeof MISSING>(provideKey, MISSING) + if (injected === MISSING) { + if (missingBehavior === true) return + if (typeof missingBehavior === 'function') { + return provideFn(...missingBehavior()) + } else { + throw new Error(`Trying to inject ${name}, which is not provided`) + } + } + return injected + } + + return { provideFn, injectFn } as const +} diff --git a/app/gui2/src/providers/visualizationConfig.ts b/app/gui2/src/providers/visualizationConfig.ts index 5400ca17553d..36845ee0bda5 100644 --- a/app/gui2/src/providers/visualizationConfig.ts +++ b/app/gui2/src/providers/visualizationConfig.ts @@ -1,6 +1,7 @@ import type { Vec2 } from '@/util/vec2' import type { VisualizationIdentifier } from 'shared/yjsModel' -import { inject, provide, type InjectionKey, type Ref } from 'vue' +import { reactive } from 'vue' +import { createContextStore } from '.' export interface VisualizationConfig { /** Possible visualization types that can be switched to. */ @@ -16,14 +17,15 @@ export interface VisualizationConfig { updateType: (type: VisualizationIdentifier) => void } -const provideKey = Symbol('visualizationConfig') as InjectionKey> +export { provideFn as provideVisualizationConfig } +const { provideFn, injectFn } = createContextStore( + 'Visualization config', + reactive, +) -export function useVisualizationConfig(): Ref { - const injected = inject(provideKey) - if (injected == null) throw new Error('AppConfig not provided') - return injected -} +// The visualization config public API should not expose the `allowMissing` parameter. It should +// look like an ordinary vue composable. -export function provideVisualizationConfig(visualizationConfig: Ref) { - provide(provideKey, visualizationConfig) +export function useVisualizationConfig() { + return injectFn() } diff --git a/app/gui2/src/stores/graph.ts b/app/gui2/src/stores/graph.ts index e5128660eb0b..46b8d1a4614c 100644 --- a/app/gui2/src/stores/graph.ts +++ b/app/gui2/src/stores/graph.ts @@ -1,19 +1,19 @@ import { useProjectStore } from '@/stores/project' import { DEFAULT_VISUALIZATION_IDENTIFIER } from '@/stores/visualization' -import { assert, assertNever } from '@/util/assert' -import { parseEnso, type Ast } from '@/util/ast' +import { Ast, AstExtended, childrenAstNodes, findAstWithRange, readAstSpan } from '@/util/ast' import { useObserveYjs } from '@/util/crdt' import type { Opt } from '@/util/opt' +import type { Rect } from '@/util/rect' import { Vec2 } from '@/util/vec2' import * as map from 'lib0/map' import * as set from 'lib0/set' import { defineStore } from 'pinia' +import type { StackItem } from 'shared/languageServerTypes' import { - rangeIntersects, + decodeRange, visMetadataEquals, type ContentRange, type ExprId, - type IdMap, type NodeMetadata, type VisualizationIdentifier, type VisualizationMetadata, @@ -33,43 +33,30 @@ export const useGraphStore = defineStore('graph', () => { const nodes = reactive(new Map()) const exprNodes = reactive(new Map()) + const nodeRects = reactive(new Map()) + const exprRects = reactive(new Map()) useObserveYjs(text, (event) => { const delta = event.changes.delta if (delta.length === 0) return - const affectedRanges: ContentRange[] = [] - function addAffectedRange(range: ContentRange) { - const lastRange = affectedRanges[affectedRanges.length - 1] - if (lastRange?.[1] === range[0]) { - lastRange[1] = range[1] - } else { - affectedRanges.push(range) - } - } - let newContent = '' let oldIdx = 0 - let newIdx = 0 for (const op of delta) { if (op.retain) { newContent += textContent.value.substring(oldIdx, oldIdx + op.retain) oldIdx += op.retain - newIdx += op.retain } else if (op.delete) { - addAffectedRange([newIdx, newIdx]) oldIdx += op.delete } else if (op.insert && typeof op.insert === 'string') { - addAffectedRange([newIdx, newIdx + op.insert.length]) newContent += op.insert - newIdx += op.insert.length } else { console.error('Unexpected Yjs operation:', op) } } newContent += textContent.value.substring(oldIdx) textContent.value = newContent - updateState(affectedRanges) + updateState() }) watch(text, (value) => { @@ -77,51 +64,47 @@ export const useGraphStore = defineStore('graph', () => { if (value != null) updateState() }) - const _parsed = ref([]) - const _parsedEnso = ref() + const _ast = ref() - function updateState(affectedRanges?: ContentRange[]) { + function updateState() { const module = proj.module if (module == null) return module.transact(() => { const idMap = module.getIdMap() const meta = module.doc.metadata - const text = module.doc.contents const textContentLocal = textContent.value - const parsed = parseBlock(0, textContentLocal, idMap) - - _parsed.value = parsed - _parsedEnso.value = parseEnso(textContentLocal) - const accessed = idMap.accessedSoFar() - - for (const nodeId of nodes.keys()) { - if (!accessed.has(nodeId)) { - nodeDeleted(nodeId) - } - } - idMap.finishAndSynchronize() - - for (const stmt of parsed) { - const exprRange: ContentRange = [stmt.exprOffset, stmt.exprOffset + stmt.expression.length] - - if (affectedRanges != null) { - while (affectedRanges[0]?.[1]! < exprRange[0]) { - affectedRanges.shift() + const ast = AstExtended.parse(textContentLocal, idMap) + const updatedMap = idMap.finishAndSynchronize() + + const methodAst = ast.isTree() + ? ast.tryMap((tree) => + getExecutedMethodAst( + tree, + textContentLocal, + proj.executionContext.getStackTop(), + updatedMap, + ), + ) + : undefined + const nodeIds = new Set() + if (methodAst) { + for (const nodeAst of methodAst.visit(getFunctionNodeExpressions)) { + const newNode = nodeFromAst(nodeAst) + const nodeId = newNode.rootSpan.astId + const node = nodes.get(nodeId) + nodeIds.add(nodeId) + if (node == null) { + nodeInserted(newNode, meta.get(nodeId)) + } else { + nodeUpdated(node, newNode, meta.get(nodeId)) } - if (affectedRanges[0] == null) break - const nodeAffected = rangeIntersects(exprRange, affectedRanges[0]) - if (!nodeAffected) continue } + } - const nodeId = stmt.expression.id - const node = nodes.get(nodeId) - const nodeMeta = meta.get(nodeId) - const nodeContent = textContentLocal.substring(exprRange[0], exprRange[1]) - if (node == null) { - nodeInserted(stmt, text, nodeContent, nodeMeta) - } else { - nodeUpdated(node, stmt, nodeContent, nodeMeta) + for (const nodeId of nodes.keys()) { + if (!nodeIds.has(nodeId)) { + nodeDeleted(nodeId) } } }) @@ -143,48 +126,27 @@ export const useGraphStore = defineStore('graph', () => { const identDefinitions = reactive(new Map()) const identUsages = reactive(new Map>()) - function nodeInserted(stmt: Statement, text: Y.Text, content: string, meta: Opt) { - const nodeId = stmt.expression.id - const node: Node = { - outerExprId: stmt.id, - content, - binding: stmt.binding ?? '', - rootSpan: stmt.expression, - position: Vec2.Zero(), - vis: undefined, - docRange: [ - Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset, -1), - Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset + stmt.expression.length), - ], - } - if (meta) { - assignUpdatedMetadata(node, meta) - } + function nodeInserted(node: Node, meta: Opt) { + const nodeId = node.rootSpan.astId + + nodes.set(nodeId, node) identDefinitions.set(node.binding, nodeId) + if (meta) assignUpdatedMetadata(node, meta) addSpanUsages(nodeId, node) - nodes.set(nodeId, node) } - function nodeUpdated(node: Node, stmt: Statement, content: string, meta: Opt) { - const nodeId = stmt.expression.id - clearSpanUsages(nodeId, node) - node.content = content - if (node.binding !== stmt.binding) { + function nodeUpdated(node: Node, newNode: Node, meta: Opt) { + const nodeId = node.rootSpan.astId + if (node.binding !== newNode.binding) { identDefinitions.delete(node.binding) - node.binding = stmt.binding ?? '' - identDefinitions.set(node.binding, nodeId) + identDefinitions.set(newNode.binding, nodeId) + node.binding = newNode.binding } - if (node.outerExprId !== stmt.id) { - node.outerExprId = stmt.id - } - if (node.rootSpan.id === stmt.expression.id) { - patchSpan(node.rootSpan, stmt.expression) - } else { - node.rootSpan = stmt.expression - } - if (meta != null) { - assignUpdatedMetadata(node, meta) + if (node.outerExprId !== newNode.outerExprId) { + node.outerExprId = newNode.outerExprId } + node.rootSpan = newNode.rootSpan + if (meta) assignUpdatedMetadata(node, meta) addSpanUsages(nodeId, node) } @@ -199,29 +161,30 @@ export const useGraphStore = defineStore('graph', () => { } function addSpanUsages(id: ExprId, node: Node) { - for (const [span, offset] of walkSpansBfs(node.rootSpan)) { - exprNodes.set(span.id, id) - const ident = node.content.substring(offset, offset + span.length) - if (span.kind === SpanKind.Ident) { - map.setIfUndefined(identUsages, ident, set.create).add(span.id) + node.rootSpan.visitRecursive((span) => { + exprNodes.set(span.astId, id) + if (span.isTree(Ast.Tree.Type.Ident)) { + map.setIfUndefined(identUsages, span.repr(), set.create).add(span.astId) + return false } - } + return true + }) } function clearSpanUsages(id: ExprId, node: Node) { - for (const [span, offset] of walkSpansBfs(node.rootSpan)) { - exprNodes.delete(span.id) - if (span.kind === SpanKind.Ident) { - const ident = node.content.substring(offset, offset + span.length) + node.rootSpan.visitRecursive((span) => { + exprNodes.delete(span.astId) + if (span.isTree(Ast.Tree.Type.Ident)) { + const ident = span.repr() const usages = identUsages.get(ident) if (usages != null) { - usages.delete(span.id) - if (usages.size === 0) { - identUsages.delete(ident) - } + usages.delete(span.astId) + if (usages.size === 0) identUsages.delete(ident) } + return false } - } + return true + }) } function nodeDeleted(id: ExprId) { @@ -233,13 +196,6 @@ export const useGraphStore = defineStore('graph', () => { } } - function patchSpan(span: Span, newSpan: Span) { - assert(span.id === newSpan.id) - span.length = newSpan.length - span.kind = newSpan.kind - span.children = newSpan.children - } - function generateUniqueIdent() { let ident: string do { @@ -282,7 +238,7 @@ export const useGraphStore = defineStore('graph', () => { function setNodeContent(id: ExprId, content: string) { const node = nodes.get(id) if (node == null) return - setExpressionContent(node.rootSpan.id, content) + setExpressionContent(node.rootSpan.astId, content) } function setExpressionContent(id: ExprId, content: string) { @@ -300,7 +256,7 @@ export const useGraphStore = defineStore('graph', () => { function replaceNodeSubexpression(nodeId: ExprId, range: ContentRange, content: string) { const node = nodes.get(nodeId) if (node == null) return - proj.module?.replaceExpressionContent(node.rootSpan.id, content, range) + proj.module?.replaceExpressionContent(node.rootSpan.astId, content, range) } function setNodePosition(nodeId: ExprId, position: Vec2) { @@ -339,13 +295,22 @@ export const useGraphStore = defineStore('graph', () => { proj.module?.updateNodeMetadata(nodeId, { vis: normalizeVisMetadata(node.vis, visible) }) } + function updateNodeRect(id: ExprId, rect: Rect) { + nodeRects.set(id, rect) + } + + function updateExprRect(id: ExprId, rect: Rect) { + exprRects.set(id, rect) + } + return { - _parsed, - _parsedEnso, + _ast, transact, nodes, exprNodes, edges, + nodeRects, + exprRects, identDefinitions, identUsages, createNode, @@ -357,6 +322,8 @@ export const useGraphStore = defineStore('graph', () => { setNodeVisualizationId, setNodeVisualizationVisible, stopCapturingUndo, + updateNodeRect, + updateExprRect, } }) @@ -366,74 +333,29 @@ function randomString() { export interface Node { outerExprId: ExprId - content: string binding: string - rootSpan: Span + rootSpan: AstExtended position: Vec2 - docRange: [Y.RelativePosition, Y.RelativePosition] vis: Opt } -export const enum SpanKind { - Root = 0, - Spacing, - Group, - Token, - Ident, - Literal, -} - -export function spanKindName(kind: SpanKind): string { - switch (kind) { - case SpanKind.Root: - return 'Root' - case SpanKind.Spacing: - return 'Spacing' - case SpanKind.Group: - return 'Group' - case SpanKind.Token: - return 'Token' - case SpanKind.Ident: - return 'Ident' - case SpanKind.Literal: - return 'Literal' - default: - assertNever(kind) - } -} - -export interface Span { - id: ExprId - kind: SpanKind - length: number - children: Span[] -} - -function walkSpansBfs( - span: Span, - offset: number = 0, - visitChildren?: (span: Span, offset: number) => boolean, -): IterableIterator<[Span, number]> { - const stack: [Span, number][] = [[span, offset]] - return { - next() { - if (stack.length === 0) { - return { done: true, value: undefined } - } - const [span, spanOffset] = stack.shift()! - if (visitChildren?.(span, spanOffset) !== false) { - let offset = spanOffset - for (let i = 0; i < span.children.length; i++) { - const child = span.children[i]! - stack.push([child, offset]) - offset += child.length - } - } - return { done: false, value: [span, spanOffset] } - }, - [Symbol.iterator]() { - return this - }, +function nodeFromAst(ast: AstExtended): Node { + if (ast.isTree(Ast.Tree.Type.Assignment)) { + return { + outerExprId: ast.astId, + binding: ast.map((t) => t.pattern).repr(), + rootSpan: ast.map((t) => t.expr), + position: Vec2.Zero(), + vis: undefined, + } + } else { + return { + outerExprId: ast.astId, + binding: '', + rootSpan: ast, + position: Vec2.Zero(), + vis: undefined, + } } } @@ -442,104 +364,62 @@ export interface Edge { target: ExprId } -interface Statement { - id: ExprId - binding: Opt - exprOffset: number - expression: Span -} - -function parseBlock(offset: number, content: string, idMap: IdMap): Statement[] { - const stmtRegex = /^( *)(([a-zA-Z0-9_]+) *= *)?(.*)$/gm - const stmts: Statement[] = [] - content.replace(stmtRegex, (stmt, indent, beforeExpr, binding, expr, index) => { - if (stmt.trim().length === 0) return stmt - const pos = offset + index + indent.length - const id = idMap.getOrInsertUniqueId([pos, pos + stmt.length - indent.length]) - const exprOffset = pos + (beforeExpr?.length ?? 0) - stmts.push({ - id, - binding, - exprOffset, - expression: parseNodeExpression(exprOffset, expr, idMap), - }) - return stmt - }) - return stmts +function getExecutedMethodAst( + ast: Ast.Tree, + code: string, + executionStackTop: StackItem, + updatedIdMap: Y.Map, +): Opt { + switch (executionStackTop.type) { + case 'ExplicitCall': { + // Assume that the provided AST matches the module in the method pointer. There is no way to + // actually verify this assumption at this point. + const ptr = executionStackTop.methodPointer + const name = ptr.name + return findModuleMethod(ast, code, name) + } + case 'LocalCall': { + const exprId = executionStackTop.expressionId + const range = lookupIdRange(updatedIdMap, exprId) + if (range == null) return + const node = findAstWithRange(ast, range) + if (node?.type === Ast.Tree.Type.Function) return node + } + } } -function parseNodeExpression(offset: number, content: string, idMap: IdMap): Span { - const root = mkSpanGroup(SpanKind.Root) - let span: Span = root - let spanOffset = offset - const stack: [Span, number][] = [] - - const tokenRegex = /(?:(".*?"|[0-9]+\b)|(\s+)|([a-zA-Z0-9_]+)|(.))/g - content.replace(tokenRegex, (token, tokLit, tokSpace, tokIdent, tokSymbol, index) => { - const pos = offset + index - if (tokSpace != null) { - span.children.push(mkSpan(idMap, SpanKind.Spacing, pos, token.length)) - } else if (tokIdent != null) { - span.children.push(mkSpan(idMap, SpanKind.Ident, pos, token.length)) - } else if (tokLit != null) { - span.children.push(mkSpan(idMap, SpanKind.Literal, pos, token.length)) - } else if (tokSymbol != null) { - if (token === '(') { - stack.push([span, spanOffset]) - span = mkSpanGroup(SpanKind.Group) - spanOffset = pos - } - - span.children.push(mkSpan(idMap, SpanKind.Token, pos, token.length)) - - if (token === ')') { - const popped = stack.pop() - if (popped != null) { - finishSpanGroup(span, idMap, spanOffset) - popped[0].children.push(span) - span = popped[0] - spanOffset = popped[1] +function* getFunctionNodeExpressions(func: Ast.Tree.Function): Generator { + if (func.body) { + if (func.body.type === Ast.Tree.Type.BodyBlock) { + for (const stmt of func.body.statements) { + if (stmt.expression && stmt.expression.type !== Ast.Tree.Type.Function) { + yield stmt.expression } } + } else { + yield func.body } - return token - }) - - let popped - while ((popped = stack.pop())) { - finishSpanGroup(span, idMap, spanOffset) - popped[0].children.push(span) - span = popped[0] - spanOffset = popped[1] } - - finishSpanGroup(root, idMap, offset) - return root } -const NULL_ID: ExprId = '00000-' as ExprId - -function mkSpanGroup(kind: SpanKind): Span { - return { - id: NULL_ID, - kind, - length: 0, - children: [], - } +function lookupIdRange(updatedIdMap: Y.Map, id: ExprId): [number, number] | undefined { + const doc = updatedIdMap.doc! + const rangeBuffer = updatedIdMap.get(id) + if (rangeBuffer == null) return + const decoded = decodeRange(rangeBuffer) + const index = Y.createAbsolutePositionFromRelativePosition(decoded[0], doc)?.index + const endIndex = Y.createAbsolutePositionFromRelativePosition(decoded[1], doc)?.index + if (index == null || endIndex == null) return + return [index, endIndex] } -function mkSpan(idMap: IdMap, kind: SpanKind, offset: number, length: number): Span { - const range: ContentRange = [offset, offset + length] - return { - id: idMap.getOrInsertUniqueId(range), - kind, - length, - children: [], +function findModuleMethod( + moduleAst: Ast.Tree, + code: string, + methodName: string, +): Opt { + for (const node of childrenAstNodes(moduleAst)) { + if (node.type === Ast.Tree.Type.Function && readAstSpan(node.name, code) === methodName) + return node } } - -function finishSpanGroup(span: Span, idMap: IdMap, offset: number) { - const totalLength = span.children.reduce((acc, child) => acc + child.length, 0) - span.length = totalLength - span.id = idMap.getOrInsertUniqueId([offset, offset + span.length]) -} diff --git a/app/gui2/src/stores/project.ts b/app/gui2/src/stores/project.ts index cb47f243a301..cf6375171021 100644 --- a/app/gui2/src/stores/project.ts +++ b/app/gui2/src/stores/project.ts @@ -1,4 +1,5 @@ -import { useGuiConfig, type GuiConfig } from '@/providers/guiConfig' +import { injectGuiConfig, type GuiConfig } from '@/providers/guiConfig' +import { bail } from '@/util/assert' import { ComputedValueRegistry } from '@/util/computedValueRegistry' import { attachProvider } from '@/util/crdt' import { AsyncQueue, rpcWithRetries as lsRpcWithRetries } from '@/util/net' @@ -7,13 +8,11 @@ import { tryQualifiedName } from '@/util/qualifiedName' import { VisualizationDataRegistry } from '@/util/visualizationDataRegistry' import { Client, RequestManager, WebSocketTransport } from '@open-rpc/client-js' import { computedAsync } from '@vueuse/core' -import { namespace } from 'd3' import * as array from 'lib0/array' import * as object from 'lib0/object' import { ObservableV2 } from 'lib0/observable' import * as random from 'lib0/random' import { defineStore } from 'pinia' -import { mainModule } from 'process' import { DataServer } from 'shared/dataServer' import { LanguageServer } from 'shared/languageServer' import type { @@ -32,6 +31,7 @@ import { DistributedProject, type ExprId, type Uuid } from 'shared/yjsModel' import { computed, markRaw, + reactive, ref, shallowRef, watch, @@ -154,6 +154,7 @@ export class ExecutionContext extends ObservableV2 queue: AsyncQueue taskRunning = false visSyncScheduled = false + desiredStack: StackItem[] = reactive([]) visualizationConfigs: Map = new Map() abortCtl = new AbortController() @@ -279,6 +280,7 @@ export class ExecutionContext extends ObservableV2 } private pushItem(item: StackItem) { + this.desiredStack.push(item) this.queue.pushTask(async (state) => { if (!state.created) return state await this.withBackoff( @@ -295,11 +297,11 @@ export class ExecutionContext extends ObservableV2 } pop() { + if (this.desiredStack.length === 1) bail('Cannot pop last item from execution context stack') + this.desiredStack.pop() this.queue.pushTask(async (state) => { if (!state.created) return state - if (state.stack.length === 0) { - throw new Error('Cannot pop from empty execution context stack') - } + if (state.stack.length === 1) bail('Cannot pop last item from execution context stack') await this.withBackoff( () => state.lsRpc.popExecutionContextItem(this.id), 'Failed to pop item from execution context stack', @@ -386,6 +388,14 @@ export class ExecutionContext extends ObservableV2 }) } + getStackBottom(): StackItem { + return this.desiredStack[0]! + } + + getStackTop(): StackItem { + return this.desiredStack[this.desiredStack.length - 1]! + } + destroy() { this.abortCtl.abort() } @@ -402,7 +412,7 @@ export const useProjectStore = defineStore('project', () => { const doc = new Y.Doc() const awareness = new Awareness(doc) - const config = useGuiConfig() + const config = injectGuiConfig() const projectName = config.value.startup?.project if (projectName == null) throw new Error('Missing project name.') diff --git a/app/gui2/src/util/assert.ts b/app/gui2/src/util/assert.ts index fb607262e461..e9249a4df4cf 100644 --- a/app/gui2/src/util/assert.ts +++ b/app/gui2/src/util/assert.ts @@ -2,8 +2,8 @@ export function assertNever(x: never): never { bail('Unexpected object: ' + x) } -export function assert(condition: boolean): asserts condition { - if (!condition) bail('Assertion failed') +export function assert(condition: boolean, message?: string): asserts condition { + if (!condition) bail(message ? `Assertion failed: ${message}` : 'Assertion failed') } export function assertUnreachable(): never { diff --git a/app/gui2/src/util/ast/__snapshots__/index.ts.snap b/app/gui2/src/util/ast/__tests__/__snapshots__/ast.test.ts.snap similarity index 91% rename from app/gui2/src/util/ast/__snapshots__/index.ts.snap rename to app/gui2/src/util/ast/__tests__/__snapshots__/ast.test.ts.snap index c5a6e6989206..47ee6d2d8dde 100644 --- a/app/gui2/src/util/ast/__snapshots__/index.ts.snap +++ b/app/gui2/src/util/ast/__tests__/__snapshots__/ast.test.ts.snap @@ -1,76 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Parsing ' foo bar -' 1`] = ` -{ - "childrenLengthInCodeParsed": 8, - "statements": [ - { - "expression": { - "arg": { - "childrenLengthInCodeParsed": 3, - "token": { - "isFree": false, - "isOperatorLexically": false, - "isTypeOrConstructor": false, - "lengthInCodeBuffer": 3, - "liftLevel": 0, - "startInCodeBuffer": 5, - "type": "Ident", - "whitespaceLengthInCodeBuffer": 0, - "whitespaceStartInCodeBuffer": 5, - }, - "type": "Ident", - "whitespaceLengthInCodeParsed": 1, - "whitespaceStartInCodeParsed": 4, - }, - "childrenLengthInCodeParsed": 7, - "func": { - "childrenLengthInCodeParsed": 3, - "token": { - "isFree": false, - "isOperatorLexically": false, - "isTypeOrConstructor": false, - "lengthInCodeBuffer": 3, - "liftLevel": 0, - "startInCodeBuffer": 1, - "type": "Ident", - "whitespaceLengthInCodeBuffer": 0, - "whitespaceStartInCodeBuffer": 1, - }, - "type": "Ident", - "whitespaceLengthInCodeParsed": 0, - "whitespaceStartInCodeParsed": 1, - }, - "type": "App", - "whitespaceLengthInCodeParsed": 0, - "whitespaceStartInCodeParsed": 1, - }, - "newline": { - "lengthInCodeBuffer": 0, - "startInCodeBuffer": 0, - "type": "Newline", - "whitespaceLengthInCodeBuffer": 0, - "whitespaceStartInCodeBuffer": 0, - }, - }, - { - "expression": undefined, - "newline": { - "lengthInCodeBuffer": 1, - "startInCodeBuffer": 8, - "type": "Newline", - "whitespaceLengthInCodeBuffer": 0, - "whitespaceStartInCodeBuffer": 8, - }, - }, - ], - "type": "BodyBlock", - "whitespaceLengthInCodeParsed": 1, - "whitespaceStartInCodeParsed": 0, -} -`; - exports[`Parsing '2 + 3 + 4' 1`] = ` diff --git a/app/gui2/src/util/ast/__tests__/ast.test.ts b/app/gui2/src/util/ast/__tests__/ast.test.ts new file mode 100644 index 000000000000..77448455e189 --- /dev/null +++ b/app/gui2/src/util/ast/__tests__/ast.test.ts @@ -0,0 +1,223 @@ +import { Token, Tree } from '@/generated/ast' +import type { LazyObject } from '@/util/parserSupport' +import { assert, expect, test } from 'vitest' +import { + astContainingChar, + childrenAstNodes, + debugAst, + parseEnso, + parseEnsoLine, + readAstOrTokenSpan, + readAstSpan, + readTokenSpan, + walkRecursive, +} from '..' + +function validateSpans(obj: LazyObject, initialPos?: number): number { + const state = { pos: initialPos ?? 0 } + const visitor = (value: LazyObject) => { + if ( + Token.isInstance(value) && + !(value.whitespaceLengthInCodeBuffer + value.lengthInCodeBuffer === 0) + ) { + assert(value.whitespaceStartInCodeBuffer === state.pos) + state.pos += value.whitespaceLengthInCodeBuffer + assert(value.startInCodeBuffer === state.pos) + state.pos += value.lengthInCodeBuffer + } else if (Tree.isInstance(value)) { + assert(value.whitespaceStartInCodeParsed === state.pos) + state.pos += value.whitespaceLengthInCodeParsed + const end = state.pos + value.childrenLengthInCodeParsed + value.visitChildren(visitor) + assert(state.pos === end) + } else { + value.visitChildren(visitor) + } + } + visitor(obj) + return state.pos +} + +const parseCases = [ + 'foo bar\n', + 'Data.read\n2 + 2', + 'Data.read File\n2 + 3', + 'Data.read "File"\n2 + 3', + 'foo bar=baz', + '2\n + 3\n + 4', +] + +test.each(parseCases)("Parsing '%s'", (code) => { + expect(debugAst(parseEnso(code))).toMatchSnapshot() +}) + +test.each(parseCases)("AST spans of '%s' are valid", (input) => { + const tree = parseEnso(input) + const endPos = validateSpans(tree) + expect(endPos).toStrictEqual(input.length) +}) + +test("Reading AST node's code", () => { + const code = 'Data.read File\n2 + 3' + const ast = parseEnso(code) + expect(readAstSpan(ast, code)).toStrictEqual(code) + assert(ast.type === Tree.Type.BodyBlock) + const statements = Array.from(ast.statements) + + assert(statements[0]?.expression != null) + expect(readAstSpan(statements[0].expression, code)).toStrictEqual('Data.read File') + assert(statements[0].expression.type === Tree.Type.App) + expect(readAstSpan(statements[0].expression.func, code)).toStrictEqual('Data.read') + expect(readAstSpan(statements[0].expression.arg, code)).toStrictEqual('File') + + assert(statements[1]?.expression != null) + expect(readAstSpan(statements[1].expression, code)).toStrictEqual('2 + 3') + assert(statements[1].expression.type === Tree.Type.OprApp) + assert(statements[1].expression.lhs != null) + assert(statements[1].expression.rhs != null) + assert(statements[1].expression.opr.ok) + expect(readAstSpan(statements[1].expression.lhs, code)).toStrictEqual('2') + expect(readTokenSpan(statements[1].expression.opr.value, code)).toStrictEqual('+') + expect(readAstSpan(statements[1].expression.rhs, code)).toStrictEqual('3') +}) + +test.each([ + [ + '2 + a', + [ + { type: Tree.Type.Number, repr: '2' }, + { type: Tree.Type.Ident, repr: 'a' }, + ], + ], + [ + 'a.b', + [ + { type: Tree.Type.Ident, repr: 'a' }, + { type: Tree.Type.Ident, repr: 'b' }, + ], + ], + [ + 'Data.read foo', + [ + { type: Tree.Type.OprApp, repr: 'Data.read' }, + { type: Tree.Type.Ident, repr: 'foo' }, + ], + ], + ['(2 + a)', [{ type: Tree.Type.OprApp, repr: '2 + a' }]], + [ + 'Data.read\n foo\n bar', + [ + { type: Tree.Type.OprApp, repr: 'Data.read' }, + { type: Tree.Type.Ident, repr: 'foo' }, + { type: Tree.Type.Ident, repr: 'bar' }, + ], + ], + [ + 'Data.read file=foo', + [ + { type: Tree.Type.OprApp, repr: 'Data.read' }, + { type: Tree.Type.Ident, repr: 'foo' }, + ], + ], + ['(', [{ type: Tree.Type.Invalid, repr: '(' }]], + [ + '(foo', + [ + { type: Tree.Type.Invalid, repr: '(' }, + { type: Tree.Type.Ident, repr: 'foo' }, + ], + ], +])("Reading children of '%s'", (code, expected) => { + const ast = parseEnsoLine(code) + const children = Array.from(childrenAstNodes(ast)) + const childrenWithExpected = children.map((child, i) => { + return { child, expected: expected[i] } + }) + for (const { child, expected } of childrenWithExpected) { + expect(child.type).toBe(expected?.type) + expect(readAstSpan(child, code)).toBe(expected?.repr) + } +}) + +test.each([ + [ + '2 + a', + [ + { tree: Tree.Type.OprApp, repr: '2 + a' }, + { tree: Tree.Type.Number, repr: '2' }, + { token: Token.Type.Digits, repr: '2' }, + { token: Token.Type.Operator, repr: '+' }, + { tree: Tree.Type.Ident, repr: 'a' }, + { token: Token.Type.Ident, repr: 'a' }, + ], + ], +])("Walking AST of '%s'", (code, expected) => { + const ast = parseEnsoLine(code) + const visited = Array.from(walkRecursive(ast)) + const visitedRepr = visited.map((visited) => { + return { + [Tree.isInstance(visited) ? 'tree' : 'token']: visited.type, + repr: readAstOrTokenSpan(visited, code), + } + }) + + expect(visitedRepr).toStrictEqual(expected) +}) + +test.each([ + [ + '2 + a', + 0, + [ + { type: Tree.Type.Number, repr: '2' }, + { type: Tree.Type.OprApp, repr: '2 + a' }, + { type: Tree.Type.BodyBlock, repr: '2 + a' }, + ], + ], + [ + 'Data.read foo', + 5, + [ + { type: Tree.Type.Ident, repr: 'read' }, + { type: Tree.Type.OprApp, repr: 'Data.read' }, + { type: Tree.Type.App, repr: 'Data.read foo' }, + { type: Tree.Type.BodyBlock, repr: 'Data.read foo' }, + ], + ], + [ + 'Data.read foo', + 4, + [ + { type: Tree.Type.OprApp, repr: 'Data.read' }, + { type: Tree.Type.App, repr: 'Data.read foo' }, + { type: Tree.Type.BodyBlock, repr: 'Data.read foo' }, + ], + ], + [ + 'Data.read foo', + 9, + [ + { type: Tree.Type.App, repr: 'Data.read foo' }, + { type: Tree.Type.BodyBlock, repr: 'Data.read foo' }, + ], + ], + [ + 'Data.', + 4, + [ + { type: Tree.Type.OprApp, repr: 'Data.' }, + { type: Tree.Type.OprSectionBoundary, repr: 'Data.' }, + { type: Tree.Type.BodyBlock, repr: 'Data.' }, + ], + ], +])("Reading AST from code '%s' and position %i", (code, position, expected) => { + const ast = parseEnso(code) + const astAtPosition = astContainingChar(position, ast) + const resultWithExpected = astAtPosition.map((ast, i) => { + return { ast, expected: expected[i] } + }) + for (const { ast, expected } of resultWithExpected) { + expect(ast.type).toBe(expected?.type) + expect(readAstSpan(ast, code)).toBe(expected?.repr) + } +}) diff --git a/app/gui2/src/util/ast/extended.ts b/app/gui2/src/util/ast/extended.ts new file mode 100644 index 000000000000..956618ef1593 --- /dev/null +++ b/app/gui2/src/util/ast/extended.ts @@ -0,0 +1,136 @@ +import * as Ast from '@/generated/ast' +import { Token, Tree } from '@/generated/ast' +import { assert } from '@/util/assert' +import type { ContentRange, ExprId, IdMap } from 'shared/yjsModel' +import { markRaw } from 'vue' +import { + childrenAstNodesOrTokens, + debugAst, + parseEnso, + parsedTreeOrTokenRange, + readAstOrTokenSpan, + visitGenerator, + visitRecursive, + walkRecursive, +} from '.' +import type { Opt } from '../opt' + +/** + * AST with additional metadata containing AST IDs and original code reference. Can only be + * constructed by parsing any enso source code string. + */ +export class AstExtended { + inner: T + private ctx: AstExtendedCtx + + public static parse(code: string): AstExtended + public static parse(code: string, idMap: IdMap): AstExtended + public static parse(code: string, idMap?: IdMap): AstExtended { + const ast = parseEnso(code) + if (idMap != null) { + visitRecursive(ast, (node) => { + const range = parsedTreeOrTokenRange(node) + idMap.getOrInsertUniqueId(range) + return true + }) + } + + const ctx = new AstExtendedCtx(code, idMap) + return new AstExtended(ast, ctx) + } + + treeTypeName(): (typeof Tree.typeNames)[number] | null { + return Tree.isInstance(this.inner) ? Tree.typeNames[this.inner.type] : null + } + + isToken( + type?: T, + ): this is AstExtended, HasIdMap> { + return Token.isInstance(this.inner) && (type == null || this.inner.type === type) + } + + isTree( + type?: T, + ): this is AstExtended, HasIdMap> { + return Tree.isInstance(this.inner) && (type == null || this.inner.type === type) + } + + private constructor(tree: T, ctx: AstExtendedCtx) { + markRaw(this) + this.inner = tree + this.ctx = ctx + } + + get astId(): CondType { + if (this.ctx.idMap != null) { + const id = this.ctx.idMap.getIfExist(parsedTreeOrTokenRange(this.inner)) + assert(id != null, 'All AST nodes should have an assigned ID') + return id as CondType + } else { + return undefined as CondType + } + } + + debug(): unknown { + return debugAst(this.inner) + } + + tryMap(mapper: (t: T) => Opt): AstExtended | undefined { + const mapped = mapper(this.inner) + if (mapped == null) return + return new AstExtended(mapped, this.ctx) + } + + map(mapper: (t: T) => T2): AstExtended { + return new AstExtended(mapper(this.inner), this.ctx) + } + + repr() { + return readAstOrTokenSpan(this.inner, this.ctx.parsedCode) + } + + span(): ContentRange { + return parsedTreeOrTokenRange(this.inner) + } + + children(): AstExtended[] { + return childrenAstNodesOrTokens(this.inner).map((child) => new AstExtended(child, this.ctx)) + } + + walkRecursive(): Generator> { + return this.visit(walkRecursive) + } + + whitespaceLength() { + return 'whitespaceLengthInCodeBuffer' in this.inner + ? this.inner.whitespaceLengthInCodeBuffer + : this.inner.whitespaceLengthInCodeParsed + } + + *visit( + visitor: (t: T) => Generator, + ): Generator> { + for (const child of visitor(this.inner)) { + yield new AstExtended(child, this.ctx) + } + } + + visitRecursive(visitor: (t: AstExtended) => boolean) { + visitGenerator(this.walkRecursive(), visitor) + } +} + +type CondType = Cond extends true + ? T + : Cond extends false + ? undefined + : T | undefined + +class AstExtendedCtx { + parsedCode: string + idMap: CondType + constructor(parsedCode: string, idMap: CondType) { + this.parsedCode = parsedCode + this.idMap = idMap + } +} diff --git a/app/gui2/src/util/ast/index.ts b/app/gui2/src/util/ast/index.ts index 2e8bee3b0b0a..61f19d788217 100644 --- a/app/gui2/src/util/ast/index.ts +++ b/app/gui2/src/util/ast/index.ts @@ -2,7 +2,13 @@ import * as Ast from '@/generated/ast' import { Token, Tree } from '@/generated/ast' import { assert } from '@/util/assert' import { parse } from '@/util/ffi' -import { LazyObject, debug } from '@/util/parserSupport' +import { LazyObject, LazySequence } from '@/util/parserSupport' +import * as map from 'lib0/map' +import type { ContentRange } from 'shared/yjsModel' +import { isResult, mapOk } from '../result' +import { AstExtended } from './extended' + +export { AstExtended } export { Ast } @@ -13,7 +19,7 @@ export function parseEnso(code: string): Tree { /** Read a single line of code * - * Is meant to be a helper for tests. If the code is multilined, an exception is raised. + * Is meant to be a helper for tests. If the code is multiline, an exception is raised. */ export function parseEnsoLine(code: string): Tree { const block = parseEnso(code) @@ -27,38 +33,34 @@ export function parseEnsoLine(code: string): Tree { } /** - * Read ast span information in `String.substring` compatible way. The returned span does not - * include left whitespace offset. + * Read span of code represented by given AST node, not including left whitespace offset. * - * @returns Object with `start` and `end` properties; index of first character in the `node` - * and first character _not_ being in the `node`. + * The AST is assumed to be generated from `code` and not modified sice then. + * Otherwise an unspecified fragment of `code` may be returned. */ -export function astSpan(node: Tree): { start: number; end: number } { - const start = node.whitespaceStartInCodeParsed + node.whitespaceLengthInCodeParsed - const end = start + node.childrenLengthInCodeParsed - return { start, end } +export function readAstOrTokenSpan(node: Tree | Token, code: string): string { + const range = parsedTreeOrTokenRange(node) + return code.substring(range[0], range[1]) } /** - * Read span of code reprsented by given AST node, not including left whitespace offset. + * Read span of code represented by given Tree. * - * The AST is assumed to be generated from `code` and not modified sice then. - * Otherwise an unspecified fragment of `code` may be returned. + * The Tree is assumed to be a part of AST generated from `code`. */ export function readAstSpan(node: Tree, code: string): string { - const { start, end } = astSpan(node) - return code.substring(start, end) + const range = parsedTreeRange(node) + return code.substring(range[0], range[1]) } /** - * Read span of code reprsented by given Token. + * Read span of code represented by given Token. * * The Token is assumed to be a part of AST generated from `code`. */ export function readTokenSpan(token: Token, code: string): string { - const begin = token.startInCodeBuffer - const end = begin + token.lengthInCodeBuffer - return code.substring(begin, end) + const range = parsedTokenRange(token) + return code.substring(range[0], range[1]) } /** @@ -67,7 +69,16 @@ export function readTokenSpan(token: Token, code: string): string { export function childrenAstNodes(obj: LazyObject): Tree[] { const children: Tree[] = [] const visitor = (obj: LazyObject) => { - if (Tree.isInstance(obj)) { + if (Tree.isInstance(obj)) children.push(obj) + else if (!Token.isInstance(obj)) obj.visitChildren(visitor) + } + obj.visitChildren(visitor) + return children +} +export function childrenAstNodesOrTokens(obj: LazyObject): (Tree | Token)[] { + const children: (Tree | Token)[] = [] + const visitor = (obj: LazyObject) => { + if (Tree.isInstance(obj) || Token.isInstance(obj)) { children.push(obj) } else { obj.visitChildren(visitor) @@ -103,190 +114,119 @@ function treePath(obj: LazyObject, pred: (node: Tree) => boolean): Tree[] { return path } -if (import.meta.vitest) { - const { test, expect } = import.meta.vitest +export function findAstWithRange( + root: Tree | Token, + range: ContentRange, +): Tree | Token | undefined { + for (const child of childrenAstNodes(root)) { + const [begin, end] = parsedTreeOrTokenRange(child) + if (begin === range[0] && end === range[1]) return child + if (begin <= range[0] && end >= range[1]) return findAstWithRange(child, range) + } +} - const parseCases = [ - 'foo bar\n', - 'Data.read\n2 + 2', - 'Data.read File\n2 + 3', - 'Data.read "File"\n2 + 3', - 'foo bar=baz', - '2\n + 3\n + 4', - ] +export function* walkRecursive(node: Tree | Token): Generator { + if (false === (yield node)) return + const stack: Iterator[] = [childrenAstNodesOrTokens(node).values()] + while (stack.length > 0) { + const next = stack[stack.length - 1]!.next() + if (next.done) stack.pop() + else if (false !== (yield next.value)) stack.push(childrenAstNodesOrTokens(next.value).values()) + } +} - test.each(parseCases)("Parsing '%s'", (code) => { - expect(debug(parseEnso(code))).toMatchSnapshot() - }) +export function visitGenerator(generator: Generator, visit: (value: T) => N): R { + let next = generator.next() + while (!next.done) next = generator.next(visit(next.value)) + return next.value +} - test.each(parseCases)("AST spans of '%s' are valid", (input) => { - const tree = parseEnso(input) - const endPos = validateSpans(tree) - expect(endPos).toStrictEqual(input.length) - }) +/** + * Recursively visit AST nodes in depth-first order. The children of a node will be skipped when + * `visit` callback returns `false`. + * + * @param node Root node of the tree to walk. It will be visited first. + * @param visit Callback that is called for each node. If it returns `false`, the children of that + * node will be skipped, and the walk will continue to the next sibling. + */ +export function visitRecursive(node: Tree | Token, visit: (node: Tree | Token) => boolean) { + visitGenerator(walkRecursive(node), visit) +} - test("Reading AST node's code", () => { - const code = 'Data.read File\n2 + 3' - const ast = parseEnso(code) - expect(readAstSpan(ast, code)).toStrictEqual(code) - assert(ast.type === Tree.Type.BodyBlock) - const statements = Array.from(ast.statements) +/** + * Read ast span information in `String.substring` compatible way. The returned span does not + * include left whitespace offset. + * + * @returns Object with `start` and `end` properties; index of first character in the `node` + * and first character _not_ being in the `node`. + */ +export function parsedTreeRange(tree: Tree): ContentRange { + const start = tree.whitespaceStartInCodeParsed + tree.whitespaceLengthInCodeParsed + const end = start + tree.childrenLengthInCodeParsed + return [start, end] +} - assert(statements[0]?.expression != null) - expect(readAstSpan(statements[0].expression, code)).toStrictEqual('Data.read File') - assert(statements[0].expression.type === Tree.Type.App) - expect(readAstSpan(statements[0].expression.func, code)).toStrictEqual('Data.read') - expect(readAstSpan(statements[0].expression.arg, code)).toStrictEqual('File') +export function parsedTokenRange(token: Token): ContentRange { + const start = token.startInCodeBuffer + const end = start + token.lengthInCodeBuffer + return [start, end] +} - assert(statements[1]?.expression != null) - expect(readAstSpan(statements[1].expression, code)).toStrictEqual('2 + 3') - assert(statements[1].expression.type === Tree.Type.OprApp) - assert(statements[1].expression.lhs != null) - assert(statements[1].expression.rhs != null) - assert(statements[1].expression.opr.ok) - expect(readAstSpan(statements[1].expression.lhs, code)).toStrictEqual('2') - expect(readTokenSpan(statements[1].expression.opr.value, code)).toStrictEqual('+') - expect(readAstSpan(statements[1].expression.rhs, code)).toStrictEqual('3') - }) +export function parsedTreeOrTokenRange(node: Tree | Token): ContentRange { + if (Tree.isInstance(node)) return parsedTreeRange(node) + else return parsedTokenRange(node) +} - test.each([ - [ - '2 + a', - [ - { type: Tree.Type.Number, repr: '2' }, - { type: Tree.Type.Ident, repr: 'a' }, - ], - ], - [ - 'a.b', - [ - { type: Tree.Type.Ident, repr: 'a' }, - { type: Tree.Type.Ident, repr: 'b' }, - ], - ], - [ - 'Data.read foo', - [ - { type: Tree.Type.OprApp, repr: 'Data.read' }, - { type: Tree.Type.Ident, repr: 'foo' }, - ], - ], - ['(2 + a)', [{ type: Tree.Type.OprApp, repr: '2 + a' }]], - [ - 'Data.read\n foo\n bar', - [ - { type: Tree.Type.OprApp, repr: 'Data.read' }, - { type: Tree.Type.Ident, repr: 'foo' }, - { type: Tree.Type.Ident, repr: 'bar' }, - ], - ], - [ - 'Data.read file=foo', - [ - { type: Tree.Type.OprApp, repr: 'Data.read' }, - { type: Tree.Type.Ident, repr: 'foo' }, - ], - ], - ['(', [{ type: Tree.Type.Invalid, repr: '(' }]], - [ - '(foo', - [ - { type: Tree.Type.Invalid, repr: '(' }, - { type: Tree.Type.Ident, repr: 'foo' }, - ], - ], - ])("Reading children of '%s'", (code, expected) => { - const ast = parseEnsoLine(code) - const children = Array.from(childrenAstNodes(ast)) - const childrenWithExpected = children.map((child, i) => { - return { child, expected: expected[i] } - }) - for (const { child, expected } of childrenWithExpected) { - expect(child.type).toBe(expected?.type) - expect(readAstSpan(child, code)).toBe(expected?.repr) +export function debugAst(obj: unknown): unknown { + if (obj instanceof LazyObject) { + const proto = Object.getPrototypeOf(obj) + const fields = Object.fromEntries( + allGetterNames(obj).map((k) => [k, debugAst((obj as any)[k])]), + ) + if (Object.hasOwnProperty.call(obj, 'type')) { + const className = proto?.constructor?.name + return { type: className, ...fields } + } else { + return fields } - }) + } else if (obj instanceof LazySequence) { + return Array.from(obj, debugAst) + } else if (isResult(obj)) { + return mapOk(obj, debugAst) + } else { + return obj + } +} - test.each([ - [ - '2 + a', - 0, - [ - { type: Tree.Type.Number, repr: '2' }, - { type: Tree.Type.OprApp, repr: '2 + a' }, - { type: Tree.Type.BodyBlock, repr: '2 + a' }, - ], - ], - [ - 'Data.read foo', - 5, - [ - { type: Tree.Type.Ident, repr: 'read' }, - { type: Tree.Type.OprApp, repr: 'Data.read' }, - { type: Tree.Type.App, repr: 'Data.read foo' }, - { type: Tree.Type.BodyBlock, repr: 'Data.read foo' }, - ], - ], - [ - 'Data.read foo', - 4, - [ - { type: Tree.Type.OprApp, repr: 'Data.read' }, - { type: Tree.Type.App, repr: 'Data.read foo' }, - { type: Tree.Type.BodyBlock, repr: 'Data.read foo' }, - ], - ], - [ - 'Data.read foo', - 9, - [ - { type: Tree.Type.App, repr: 'Data.read foo' }, - { type: Tree.Type.BodyBlock, repr: 'Data.read foo' }, - ], - ], - [ - 'Data.', - 4, - [ - { type: Tree.Type.OprApp, repr: 'Data.' }, - { type: Tree.Type.OprSectionBoundary, repr: 'Data.' }, - { type: Tree.Type.BodyBlock, repr: 'Data.' }, - ], - ], - ])("Reading AST from code '%s' and position %i", (code, position, expected) => { - const ast = parseEnso(code) - const astAtPosition = astContainingChar(position, ast) - const resultWithExpected = astAtPosition.map((ast, i) => { - return { ast, expected: expected[i] } - }) - for (const { ast, expected } of resultWithExpected) { - expect(ast.type).toBe(expected?.type) - expect(readAstSpan(ast, code)).toBe(expected?.repr) +const protoGetters = new Map() +function allGetterNames(obj: object): string[] { + let proto = Object.getPrototypeOf(obj) + return map.setIfUndefined(protoGetters, proto, () => { + const props = new Map() + do { + for (const [name, prop] of Object.entries(Object.getOwnPropertyDescriptors(proto))) { + if (!props.has(name)) props.set(name, prop) + } + } while ((proto = Object.getPrototypeOf(proto))) + const getters = new Set() + for (const [name, prop] of props.entries()) { + if (prop.get != null && prop.configurable && !debugHideFields.includes(name)) { + getters.add(name) + } } + return [...getters] }) } -function validateSpans(obj: LazyObject, initialPos?: number): number { - const state = { pos: initialPos ?? 0 } - const visitor = (value: LazyObject) => { - if ( - Token.isInstance(value) && - !(value.whitespaceLengthInCodeBuffer + value.lengthInCodeBuffer === 0) - ) { - assert(value.whitespaceStartInCodeBuffer === state.pos) - state.pos += value.whitespaceLengthInCodeBuffer - assert(value.startInCodeBuffer === state.pos) - state.pos += value.lengthInCodeBuffer - } else if (Tree.isInstance(value)) { - assert(value.whitespaceStartInCodeParsed === state.pos) - state.pos += value.whitespaceLengthInCodeParsed - const end = state.pos + value.childrenLengthInCodeParsed - value.visitChildren(visitor) - assert(state.pos === end) - } else { - value.visitChildren(visitor) - } - } - visitor(obj) - return state.pos -} +const debugHideFields = [ + '_v', + '__proto__', + 'codeReprBegin', + 'codeReprLen', + 'leftOffsetCodeReprBegin', + 'leftOffsetCodeReprLen', + 'leftOffsetVisible', + 'spanLeftOffsetCodeReprBegin', + 'spanLeftOffsetCodeReprLen', + 'spanLeftOffsetVisible', +] diff --git a/app/gui2/src/util/codemirror.ts b/app/gui2/src/util/codemirror.ts index 0dfb73e69b78..c183a35010da 100644 --- a/app/gui2/src/util/codemirror.ts +++ b/app/gui2/src/util/codemirror.ts @@ -17,7 +17,7 @@ export { EditorView, tooltips, type TooltipView } from '@codemirror/view' export { type Highlighter } from '@lezer/highlight' export { minimalSetup } from 'codemirror' export { yCollab } from 'y-codemirror.next' -import { Ast, childrenAstNodes, parseEnso } from '@/util/ast' +import { Ast, AstExtended } from '@/util/ast' import { Language, LanguageSupport, @@ -35,67 +35,67 @@ import { Tree, type Input, type PartialParse, + type SyntaxNode, } from '@lezer/common' import { styleTags, tags } from '@lezer/highlight' -import type { EditorView } from 'codemirror' +import { EditorView } from 'codemirror' -const nodeTypes: NodeType[] = [] -for (const potentialAstNodeType of Object.values(Ast.Tree)) { - if ( - 'prototype' in potentialAstNodeType && - potentialAstNodeType.prototype instanceof Ast.Tree.AbstractBase && - potentialAstNodeType !== Ast.Tree.AbstractBase - ) { - const view = new DataView(new Uint8Array().buffer) - const tree = new (potentialAstNodeType as new ( - view: DataView, - ) => Ast.Tree.AbstractBase & { type: Ast.Tree.Type })(view) - nodeTypes.push(NodeType.define({ id: tree.type, name: tree.constructor.name })) - } -} +type AstNode = AstExtended + +const nodeTypes: NodeType[] = [ + ...Ast.Tree.typeNames.map((name, id) => NodeType.define({ id, name })), + ...Ast.Token.typeNames.map((name, id) => + NodeType.define({ id: id + Ast.Tree.typeNames.length, name: 'Token' + name }), + ), +] const nodeSet = new NodeSet(nodeTypes).extend( styleTags({ Ident: tags.variableName, - Private: tags.variableName, + 'Private!': tags.variableName, Number: tags.number, - Wildcard: tags.variableName, - TextLiteral: tags.string, - OprApp: tags.operator, + 'Wildcard!': tags.variableName, + 'TextLiteral!': tags.string, + 'OprApp!': tags.operator, + TokenOperator: tags.operator, + 'Assignment/TokenOperator': tags.definitionOperator, UnaryOprApp: tags.operator, - Function: tags.function(tags.variableName), + 'Function/Ident': tags.function(tags.variableName), ForeignFunction: tags.function(tags.variableName), - Import: tags.function(tags.moduleKeyword), + 'Import!': tags.function(tags.moduleKeyword), Export: tags.function(tags.moduleKeyword), Lambda: tags.function(tags.variableName), Documented: tags.docComment, ConstructorDefinition: tags.function(tags.variableName), }), foldNodeProp.add({ - Function: (node) => node, + Function: (node) => node.lastChild, ArgumentBlockApplication: (node) => node, OperatorBlockApplication: (node) => node, }), ) -export const astProp = new NodeProp({ perNode: true }) +export const astProp = new NodeProp({ perNode: true }) function astToCodeMirrorTree( nodeSet: NodeSet, - tree: Ast.Tree, + ast: AstNode, props?: readonly [number | NodeProp, any][] | undefined, ): Tree { - const begin = tree.whitespaceStartInCodeParsed + tree.whitespaceLengthInCodeParsed - return new Tree( - nodeSet.types[tree.type]!, - Array.from(childrenAstNodes(tree), (tree) => astToCodeMirrorTree(nodeSet, tree)), - Array.from( - childrenAstNodes(tree), - (child) => child.whitespaceStartInCodeParsed + child.whitespaceLengthInCodeParsed - begin, - ), - tree.childrenLengthInCodeParsed, - [...(props ?? []), [astProp, tree]], + const [start, end] = ast.span() + const children = ast.children() + + const hasSingleTokenChild = children.length === 1 && children[0]!.isToken() + const childrenToConvert = hasSingleTokenChild ? [] : children + + const tree = new Tree( + nodeSet.types[ast.inner.type + (ast.isToken() ? Ast.Tree.typeNames.length : 0)]!, + childrenToConvert.map((child) => astToCodeMirrorTree(nodeSet, child)), + childrenToConvert.map((child) => child.span()[0] - start), + end - start, + [...(props ?? []), [astProp, ast]], ) + return tree } const facet = defineLanguageFacet() @@ -118,7 +118,7 @@ class EnsoParser extends Parser { const code = input.read(0, input.length) if (code !== self.cachedCode || self.cachedTree == null) { self.cachedCode = code - const ast = parseEnso(code) + const ast = AstExtended.parse(code) self.cachedTree = astToCodeMirrorTree(self.nodeSet, ast, [[languageDataProp, facet]]) } return self.cachedTree @@ -140,18 +140,23 @@ export function enso() { } export function hoverTooltip( - create: (node: Ast.Tree) => TooltipView | ((view: EditorView) => TooltipView) | null | undefined, + create: ( + ast: AstNode, + syntax: SyntaxNode, + ) => TooltipView | ((view: EditorView) => TooltipView) | null | undefined, ) { return originalHoverTooltip((view, pos, side) => { - const node = syntaxTree(view.state).resolveInner(pos, side) - const ast = node.tree?.prop(astProp) - if (ast == null) return null - const domOrCreate = create(ast) + const syntaxNode = syntaxTree(view.state).resolveInner(pos, side) + const astNode = syntaxNode.tree?.prop(astProp) + if (astNode == null) return null + const domOrCreate = create(astNode, syntaxNode) if (domOrCreate == null) return null + return { - pos: node.from, - end: node.to, + pos: syntaxNode.from, + end: syntaxNode.to, above: true, + arrow: true, create: typeof domOrCreate !== 'function' ? () => domOrCreate : domOrCreate, } }) diff --git a/app/gui2/src/util/navigator.ts b/app/gui2/src/util/navigator.ts index 8090a7a2a81b..8142b9eca84d 100644 --- a/app/gui2/src/util/navigator.ts +++ b/app/gui2/src/util/navigator.ts @@ -11,6 +11,7 @@ function elemRect(target: Element | undefined): Rect { return Rect.Zero() } +export type NavigatorComposable = ReturnType export function useNavigator(viewportNode: Ref) { const size = useResizeObserver(viewportNode) const center = ref(Vec2.Zero()) diff --git a/app/gui2/src/util/net.ts b/app/gui2/src/util/net.ts index 619cc859d183..2790b7611926 100644 --- a/app/gui2/src/util/net.ts +++ b/app/gui2/src/util/net.ts @@ -1,4 +1,4 @@ -import { Err, Error, Ok, rejectionToResult, type Result } from '@/util/result' +import { Err, Ok, ResultError, rejectionToResult, type Result } from '@/util/result' import { wait } from 'lib0/promise' import { LsRpcError } from 'shared/languageServer' @@ -12,7 +12,7 @@ export interface BackoffOptions { * When this function returns `false`, the backoff is immediately aborted. When this function is * not provided, the backoff will always continue until the maximum number of retries is reached. */ - onBeforeRetry?: (error: Error, retryCount: number, delay: number) => boolean | void + onBeforeRetry?: (error: ResultError, retryCount: number, delay: number) => boolean | void } const defaultBackoffOptions: Required> = { @@ -107,9 +107,7 @@ export class AsyncQueue { async waitForCompletion(): Promise { let lastState: State do { - console.log('this.lastTask', this.lastTask) lastState = await this.lastTask - console.log('lastState', lastState) } while (this.taskRunning) return lastState } @@ -180,7 +178,7 @@ if (import.meta.vitest) { const promise = exponentialBackoff(task, { maxRetries: 4 }) vi.runAllTimersAsync() const result = await promise - expect(result).toEqual({ ok: false, error: new Error(1) }) + expect(result).toEqual({ ok: false, error: new ResultError(1) }) expect(task).toHaveBeenCalledTimes(5) }) @@ -226,8 +224,8 @@ if (import.meta.vitest) { vi.runAllTimersAsync() await promise expect(onBeforeRetry).toHaveBeenCalledTimes(2) - expect(onBeforeRetry).toHaveBeenNthCalledWith(1, new Error(3), 0, 1000) - expect(onBeforeRetry).toHaveBeenNthCalledWith(2, new Error(2), 1, 2000) + expect(onBeforeRetry).toHaveBeenNthCalledWith(1, new ResultError(3), 0, 1000) + expect(onBeforeRetry).toHaveBeenNthCalledWith(2, new ResultError(2), 1, 2000) }) }) } diff --git a/app/gui2/src/util/parserSupport.ts b/app/gui2/src/util/parserSupport.ts index 4f8e6ce6550b..daf0f22f4dd4 100644 --- a/app/gui2/src/util/parserSupport.ts +++ b/app/gui2/src/util/parserSupport.ts @@ -2,43 +2,7 @@ export { type Result } from '@/util/result' import { bail } from '@/util/assert' -import { Err, Error, Ok, type Result } from '@/util/result' - -export type Primitive = { - type: 'primitive' - value: boolean | number | bigint | string -} -export type DynValue = Primitive | DynSequence | DynResult | DynOption | DynObject -export type DynResult = { - type: 'result' - value: Result -} -export type DynSequence = { - type: 'sequence' - value: Iterable -} -export type DynOption = { - type: 'option' - value: DynValue | undefined -} -export type DynObject = { - type: 'object' - getFields: () => [string, DynValue][] -} - -export const Dyn = { - Primitive: (value: boolean | number | bigint | string): DynValue => ({ - type: 'primitive', - value: value, - }), - Result: (value: Result): DynValue => ({ type: 'result', value: value }), - Sequence: (value: Iterable): DynValue => ({ type: 'sequence', value: value }), - Option: (value: DynValue | undefined): DynValue => ({ type: 'option', value: value }), - Object: (value: LazyObject): DynValue => ({ - type: 'object', - getFields: value.fields.bind(value), - }), -} as const +import { Err, Ok, type Result } from '@/util/result' export type ObjectVisitor = (object: LazyObject) => boolean | void export type ObjectAddressVisitor = (view: DataView, address: number) => boolean | void @@ -48,13 +12,10 @@ export abstract class LazyObject { protected readonly _v: DataView protected constructor(view: DataView) { + if (view == null) throw new Error('WTF?') this._v = view } - fields(): [string, DynValue][] { - return [] - } - visitChildren(_visitor: ObjectVisitor): boolean { return false } @@ -202,7 +163,7 @@ export function readSequence( return new LazySequence(offset, size, end, (offset: number) => reader(data, offset)) } -class LazySequence implements Iterator { +export class LazySequence implements Iterator { private offset: number private readonly step: number private readonly end: number @@ -242,42 +203,3 @@ export function readEnum(readers: Reader[], view: DataView, address: numbe const reader = readers[discriminant] ?? bail(`Invalid enum discriminant: ${discriminant}`) return reader(data, 4) } - -export function debug(obj: LazyObject): any { - return debug_(Dyn.Object(obj)) -} - -function debug_(value: DynValue): any { - switch (value.type) { - case 'sequence': - return Array.from(value.value, debug_) - case 'result': - if (value.value.ok) return Ok(debug_(value.value.value)) - else return Err(debug_(value.value.error.payload)) - case 'option': - if (value.value != null) return debug_(value.value) - else return undefined - case 'object': { - // FIXME: Include the `hide` reflect property in the schema, and apply it during code generation to avoid magic - // strings here. - const hide = [ - 'codeReprBegin', - 'codeReprLen', - 'leftOffsetCodeReprBegin', - 'leftOffsetCodeReprLen', - 'leftOffsetVisible', - 'spanLeftOffsetCodeReprBegin', - 'spanLeftOffsetCodeReprLen', - 'spanLeftOffsetVisible', - ] - return Object.fromEntries( - value - .getFields() - .filter(([name, _]) => !hide.includes(name)) - .map(([name, value]) => [name, debug_(value)]), - ) - } - case 'primitive': - return value.value - } -} diff --git a/app/gui2/src/util/result.ts b/app/gui2/src/util/result.ts index b6e61ac3f3ad..8476b8a933c0 100644 --- a/app/gui2/src/util/result.ts +++ b/app/gui2/src/util/result.ts @@ -2,14 +2,14 @@ import { isSome, type Opt } from '@/util/opt' export type Result = | { ok: true; value: T } - | { ok: false; error: Error } + | { ok: false; error: ResultError } export function Ok(data: T): Result { return { ok: true, value: data } } export function Err(error: E): Result { - return { ok: false, error: new Error(error) } + return { ok: false, error: new ResultError(error) } } export function okOr(data: Opt, error: E): Result { @@ -27,7 +27,17 @@ export function mapOk(result: Result, f: (value: T) => U): Result else return result } -export class Error { +export function isResult(v: unknown): v is Result { + return ( + v != null && + typeof v === 'object' && + 'ok' in v && + typeof v.ok === 'boolean' && + ('value' in v || ('error' in v && v.error instanceof ResultError)) + ) +} + +export class ResultError { payload: E context: (() => string)[] diff --git a/app/gui2/src/util/selection.ts b/app/gui2/src/util/selection.ts new file mode 100644 index 000000000000..c12c8e9e7546 --- /dev/null +++ b/app/gui2/src/util/selection.ts @@ -0,0 +1,137 @@ +import { selectionMouseBindings } from '@/bindings' +import { usePointer } from '@/util/events' +import type { NavigatorComposable } from '@/util/navigator' +import type { Rect } from '@/util/rect' +import type { Vec2 } from '@/util/vec2' +import { computed, proxyRefs, reactive, ref, shallowRef } from 'vue' + +export type SelectionComposable = ReturnType> +export function useSelection( + navigator: NavigatorComposable, + elementRects: Map, + margin: number, + callbacks: { + onSelected?: (element: T) => void + onDeselected?: (element: T) => void + } = {}, +) { + const anchor = shallowRef() + const initiallySelected = new Set() + const selected = reactive(new Set()) + + function readInitiallySelected() { + initiallySelected.clear() + for (const id of selected) initiallySelected.add(id) + } + + function setSelection(newSelection: Set) { + for (const id of newSelection) + if (!selected.has(id)) { + selected.add(id) + callbacks.onSelected?.(id) + } + for (const id of selected) + if (!newSelection.has(id)) { + selected.delete(id) + callbacks.onDeselected?.(id) + } + } + + function execAdd() { + setSelection(new Set([...initiallySelected, ...elementsToSelect.value])) + } + + function execRemove() { + const newSelection = new Set([...initiallySelected]) + for (const t of elementsToSelect.value) newSelection.delete(t) + setSelection(newSelection) + } + + const selectionEventHandler = selectionMouseBindings.handler({ + replace() { + setSelection(elementsToSelect.value) + }, + add: execAdd, + remove: execRemove, + toggle() { + const numCommon = countCommonInSets(initiallySelected, elementsToSelect.value) + const adding = numCommon * 2 <= elementsToSelect.value.size + if (adding) execAdd() + else execRemove() + }, + invert() { + const newSelection = new Set(initiallySelected) + for (const id of elementsToSelect.value) { + if (initiallySelected.has(id)) newSelection.delete(id) + else newSelection.add(id) + } + setSelection(newSelection) + }, + }) + + const intersectingElements = computed>(() => { + if (!pointer.dragging || anchor.value == null || navigator.sceneMousePos == null) { + return new Set() + } + const navigatorSpaceMargin = margin / navigator.scale + + const a = navigator.sceneMousePos + const b = anchor.value + + const left = Math.min(a.x, b.x) - navigatorSpaceMargin + const right = Math.max(a.x, b.x) + navigatorSpaceMargin + const top = Math.min(a.y, b.y) - navigatorSpaceMargin + const bottom = Math.max(a.y, b.y) + navigatorSpaceMargin + const intersectingElements = new Set() + for (const [id, rect] of elementRects) { + const rectLeft = rect.pos.x + const rectRight = rectLeft + rect.size.x + const rectTop = rect.pos.y + const rectBottom = rectTop + rect.size.y + if (left <= rectRight && right >= rectLeft && top <= rectBottom && bottom >= rectTop) { + intersectingElements.add(id) + } + } + return intersectingElements + }) + + const overrideElemsToSelect = ref>() + const elementsToSelect = computed(() => overrideElemsToSelect.value ?? intersectingElements.value) + + function handleSelectionOf(event: MouseEvent, elements: Set) { + readInitiallySelected() + overrideElemsToSelect.value = elements + selectionEventHandler(event) + overrideElemsToSelect.value = undefined + } + + const pointer = usePointer((pos, event, eventType) => { + if (eventType === 'start') { + readInitiallySelected() + } else if (pointer.dragging && anchor.value == null) { + anchor.value = navigator.sceneMousePos?.copy() + } else if (eventType === 'stop') { + anchor.value = undefined + initiallySelected.clear() + } + selectionEventHandler(event) + }) + return proxyRefs({ + selected, + anchor, + selectAll: () => { + for (const id of elementRects.keys()) selected.add(id) + }, + deselectAll: () => selected.clear(), + isSelected: (element: T) => selected.has(element), + handleSelectionOf, + mouseHandler: selectionEventHandler, + events: pointer.events, + }) +} + +function countCommonInSets(a: Set, b: Set): number { + let count = 0 + for (const item in a) count += +b.has(item) + return count +} diff --git a/app/gui2/src/util/vec2.ts b/app/gui2/src/util/vec2.ts index 8bdc6c281c5a..038771391334 100644 --- a/app/gui2/src/util/vec2.ts +++ b/app/gui2/src/util/vec2.ts @@ -25,9 +25,9 @@ export class Vec2 { scale(scalar: number): Vec2 { return new Vec2(this.x * scalar, this.y * scalar) } - distanceSquare(a: Vec2, b: Vec2): number { - const dx = a.x - b.x - const dy = a.y - b.y + distanceSquare(other: Vec2): number { + const dx = this.x - other.x + const dy = this.y - other.y return dx * dx + dy * dy } add(other: Vec2): Vec2 { diff --git a/app/gui2/src/util/visualizationDataRegistry.ts b/app/gui2/src/util/visualizationDataRegistry.ts index e1e98bbc2cbb..82594af7ae1b 100644 --- a/app/gui2/src/util/visualizationDataRegistry.ts +++ b/app/gui2/src/util/visualizationDataRegistry.ts @@ -1,12 +1,12 @@ -import type { ExecutionContext } from '@/stores/project.ts' -import { OutboundPayload, VisualizationUpdate } from 'shared/binaryProtocol.ts' -import type { DataServer } from 'shared/dataServer.ts' +import type { ExecutionContext } from '@/stores/project' +import { OutboundPayload, VisualizationUpdate } from 'shared/binaryProtocol' +import type { DataServer } from 'shared/dataServer' import type { ExpressionUpdatePayload, MethodCall, ProfilingInfo, Uuid, -} from 'shared/languageServerTypes.ts' +} from 'shared/languageServerTypes' import { reactive } from 'vue' export interface ExpressionInfo {