mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 11:52:59 +03:00
fix edge dragging and method argument assignment (#8388)
Fixed broken edge dragging and creating nodes from ports. Added basic support for multiple output ports, driven by already existing analysis of the port binding structure. Those constructs are not yet supported by the engine (hence the error in code), but the IDE has easier time already dealing with ports as individual binding expressions, not whole nodes. <img width="865" alt="image" src="https://github.com/enso-org/enso/assets/919491/73126593-05c0-4553-ba6d-dad97d083c48"> Also improved the ability to interpret applied method arguments. Different cases of dynamic, static calls or partially applied functions are now properly supported. <img width="961" alt="image" src="https://github.com/enso-org/enso/assets/919491/ffe02d79-841c-411d-a218-de89c2522f7b">
This commit is contained in:
parent
9b7e3d0f16
commit
1ad7a4bf5a
@ -185,6 +185,7 @@ const editorStyle = computed(() => {
|
||||
@keydown.enter.stop
|
||||
@wheel.stop.passive
|
||||
@pointerdown.stop
|
||||
@contextmenu.stop
|
||||
>
|
||||
<div class="resize-handle" v-on="resize.events" @dblclick="resetSize">
|
||||
<svg viewBox="0 0 16 16">
|
||||
|
@ -34,7 +34,7 @@ const props = defineProps<{
|
||||
navigator: ReturnType<typeof useNavigator>
|
||||
initialContent: string
|
||||
initialCaretPosition: ContentRange
|
||||
sourceNode: Opt<ExprId>
|
||||
sourcePort: Opt<ExprId>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -44,17 +44,15 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
function getInitialContent(): string {
|
||||
if (props.sourceNode == null) return props.initialContent
|
||||
const sourceNode = props.sourceNode
|
||||
const sourceNodeName = graphStore.db.getNodeMainOutputPortIdentifier(sourceNode)
|
||||
if (props.sourcePort == null) return props.initialContent
|
||||
const sourceNodeName = graphStore.db.getOutputPortIdentifier(props.sourcePort)
|
||||
const sourceNodeNameWithDot = sourceNodeName ? sourceNodeName + '.' : ''
|
||||
return sourceNodeNameWithDot + props.initialContent
|
||||
}
|
||||
|
||||
function getInitialCaret(): ContentRange {
|
||||
if (props.sourceNode == null) return props.initialCaretPosition
|
||||
const sourceNode = props.sourceNode
|
||||
const sourceNodeName = graphStore.db.getNodeMainOutputPortIdentifier(sourceNode)
|
||||
if (props.sourcePort == null) return props.initialCaretPosition
|
||||
const sourceNodeName = graphStore.db.getOutputPortIdentifier(props.sourcePort)
|
||||
const sourceNodeNameWithDot = sourceNodeName ? sourceNodeName + '.' : ''
|
||||
return [
|
||||
props.initialCaretPosition[0] + sourceNodeNameWithDot.length,
|
||||
|
@ -110,12 +110,13 @@ function targetComponentBrowserPosition() {
|
||||
/** The current position of the component browser. */
|
||||
const componentBrowserPosition = ref<Vec2>(Vec2.Zero)
|
||||
|
||||
function sourceNodeForSelection() {
|
||||
function sourcePortForSelection() {
|
||||
if (graphStore.editedNodeInfo != null) return undefined
|
||||
return nodeSelection.selected.values().next().value
|
||||
const firstSelectedNode = set.first(nodeSelection.selected)
|
||||
return graphStore.db.getNodeFirstOutputPort(firstSelectedNode)
|
||||
}
|
||||
|
||||
const componentBrowserSourceNode = ref<ExprId | undefined>(sourceNodeForSelection())
|
||||
const componentBrowserSourcePort = ref<ExprId | undefined>(sourcePortForSelection())
|
||||
|
||||
useEvent(window, 'keydown', (event) => {
|
||||
interactionBindingsHandler(event) || graphBindingsHandler(event) || codeEditorHandler(event)
|
||||
@ -231,7 +232,7 @@ interaction.setWhen(nodeIsBeingEdited, editingNode)
|
||||
const creatingNode: Interaction = {
|
||||
init: () => {
|
||||
componentBrowserInputContent.value = ''
|
||||
componentBrowserSourceNode.value = sourceNodeForSelection()
|
||||
componentBrowserSourcePort.value = sourcePortForSelection()
|
||||
componentBrowserPosition.value = targetComponentBrowserPosition()
|
||||
componentBrowserVisible.value = true
|
||||
},
|
||||
@ -419,7 +420,7 @@ async function readNodeFromClipboard() {
|
||||
}
|
||||
|
||||
function handleNodeOutputPortDoubleClick(id: ExprId) {
|
||||
componentBrowserSourceNode.value = id
|
||||
componentBrowserSourcePort.value = id
|
||||
const placementEnvironment = environmentForNodes([id].values())
|
||||
componentBrowserPosition.value = previousNodeDictatedPlacement(
|
||||
DEFAULT_NODE_SIZE,
|
||||
@ -458,7 +459,7 @@ function handleNodeOutputPortDoubleClick(id: ExprId) {
|
||||
:position="componentBrowserPosition"
|
||||
:initialContent="componentBrowserInputContent"
|
||||
:initialCaretPosition="graphStore.editedNodeInfo?.range ?? [0, 0]"
|
||||
:sourceNode="componentBrowserSourceNode"
|
||||
:sourcePort="componentBrowserSourcePort"
|
||||
@accepted="onComponentBrowserCommit"
|
||||
@closed="onComponentBrowserCancel"
|
||||
@canceled="onComponentBrowserCancel"
|
||||
|
@ -55,7 +55,7 @@ function createNodeFromEdgeDrop(source: ExprId, graphNavigator: GraphNavigator)
|
||||
}
|
||||
|
||||
function createEdge(source: ExprId, target: ExprId) {
|
||||
const ident = graph.db.getIdentifierOfConnection(source)
|
||||
const ident = graph.db.getOutputPortIdentifier(source)
|
||||
if (ident == null) return
|
||||
// TODO: Check alias analysis to see if the binding is shadowed.
|
||||
graph.setExpressionContent(target, ident)
|
||||
|
@ -13,7 +13,8 @@ import { displayedIconOf } from '@/util/getIconName'
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { Rect } from '@/util/rect'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import type { ContentRange, VisualizationIdentifier } from 'shared/yjsModel'
|
||||
import { setIfUndefined } from 'lib0/map'
|
||||
import type { ContentRange, ExprId, VisualizationIdentifier } from 'shared/yjsModel'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
const MAXIMUM_CLICK_LENGTH_MS = 300
|
||||
@ -34,16 +35,19 @@ const emit = defineEmits<{
|
||||
delete: []
|
||||
replaceSelection: []
|
||||
'update:selected': [selected: boolean]
|
||||
outputPortClick: []
|
||||
outputPortDoubleClick: []
|
||||
outputPortClick: [portId: ExprId]
|
||||
outputPortDoubleClick: [portId: ExprId]
|
||||
'update:edited': [cursorPosition: number]
|
||||
}>()
|
||||
|
||||
const nodeSelection = injectGraphSelection(true)
|
||||
const graph = useGraphStore()
|
||||
const isSourceOfDraggedEdge = computed(
|
||||
() => graph.unconnectedEdge?.source === props.node.rootSpan.astId,
|
||||
)
|
||||
|
||||
const outputPortsSet = computed(() => {
|
||||
const bindings = graph.db.nodeOutputPorts.lookup(nodeId.value)
|
||||
if (bindings.size === 0) return new Set([nodeId.value])
|
||||
return bindings
|
||||
})
|
||||
|
||||
const nodeId = computed(() => props.node.rootSpan.astId)
|
||||
const rootNode = ref<HTMLElement>()
|
||||
@ -66,18 +70,10 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const outputHovered = ref(false)
|
||||
const hoverAnimation = useApproach(
|
||||
() => (outputHovered.value || isSourceOfDraggedEdge.value ? 1 : 0),
|
||||
50,
|
||||
0.01,
|
||||
)
|
||||
|
||||
const bgStyleVariables = computed(() => {
|
||||
return {
|
||||
'--node-width': `${nodeSize.value.x}px`,
|
||||
'--node-height': `${nodeSize.value.y}px`,
|
||||
'--hover-animation': `${hoverAnimation.value}`,
|
||||
}
|
||||
})
|
||||
|
||||
@ -120,7 +116,7 @@ const dragPointer = usePointer((pos, event, type) => {
|
||||
})
|
||||
|
||||
const expressionInfo = computed(() => graph.db.getExpressionInfo(nodeId.value))
|
||||
const outputTypeName = computed(() => expressionInfo.value?.typename ?? 'Unknown')
|
||||
const outputPortLabel = computed(() => expressionInfo.value?.typename ?? 'Unknown')
|
||||
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
|
||||
const suggestionEntry = computed(() => graph.db.nodeMainSuggestion.lookup(nodeId.value))
|
||||
const color = computed(() => graph.db.getNodeColorStyle(nodeId.value))
|
||||
@ -129,7 +125,7 @@ const icon = computed(() => {
|
||||
return displayedIconOf(
|
||||
suggestionEntry.value,
|
||||
expressionInfo?.methodCall?.methodPointer,
|
||||
outputTypeName.value,
|
||||
outputPortLabel.value,
|
||||
)
|
||||
})
|
||||
|
||||
@ -189,12 +185,56 @@ function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): numb
|
||||
return 0
|
||||
}
|
||||
|
||||
const handlePortClick = useDoubleClick(
|
||||
() => emit('outputPortClick'),
|
||||
() => {
|
||||
emit('outputPortDoubleClick')
|
||||
const handlePortClick = useDoubleClick<[portId: ExprId]>(
|
||||
(portId) => emit('outputPortClick', portId),
|
||||
(portId) => {
|
||||
emit('outputPortDoubleClick', portId)
|
||||
},
|
||||
).handleClick
|
||||
interface PortData {
|
||||
clipRange: [number, number]
|
||||
label: string
|
||||
portId: ExprId
|
||||
}
|
||||
|
||||
const outputPorts = computed((): PortData[] => {
|
||||
const ports = outputPortsSet.value
|
||||
const numPorts = ports.size
|
||||
return Array.from(ports, (portId, index) => {
|
||||
const labelIdent = numPorts > 1 ? graph.db.getOutputPortIdentifier(portId) + ': ' : ''
|
||||
const labelType = graph.db.getExpressionInfo(portId)?.typename ?? 'Unknown'
|
||||
return {
|
||||
clipRange: [index / numPorts, (index + 1) / numPorts],
|
||||
label: labelIdent + labelType,
|
||||
portId,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const outputHovered = ref<ExprId>()
|
||||
const hoverAnimations = new Map<ExprId, ReturnType<typeof useApproach>>()
|
||||
watchEffect(() => {
|
||||
const ports = outputPortsSet.value
|
||||
for (const key of hoverAnimations.keys()) if (!ports.has(key)) hoverAnimations.delete(key)
|
||||
for (const port of outputPortsSet.value) {
|
||||
setIfUndefined(hoverAnimations, port, () =>
|
||||
useApproach(
|
||||
() => (outputHovered.value === port || graph.unconnectedEdge?.target === port ? 1 : 0),
|
||||
50,
|
||||
0.01,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function portGroupStyle(port: PortData) {
|
||||
const [start, end] = port.clipRange
|
||||
return {
|
||||
'--hover-animation': hoverAnimations.get(port.portId)?.value ?? 0,
|
||||
'--port-clip-start': start,
|
||||
'--port-clip-end': end,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -245,14 +285,20 @@ const handlePortClick = useDoubleClick(
|
||||
</div>
|
||||
<svg class="bgPaths" :style="bgStyleVariables">
|
||||
<rect class="bgFill" />
|
||||
<rect
|
||||
class="outputPortHoverArea"
|
||||
@pointerenter="outputHovered = true"
|
||||
@pointerleave="outputHovered = false"
|
||||
@pointerdown="handlePortClick()"
|
||||
/>
|
||||
<rect class="outputPort" />
|
||||
<text class="outputTypeName">{{ outputTypeName }}</text>
|
||||
<template v-for="port of outputPorts" :key="port.portId">
|
||||
<g :style="portGroupStyle(port)">
|
||||
<g class="portClip">
|
||||
<rect
|
||||
class="outputPortHoverArea"
|
||||
@pointerenter="outputHovered = port.portId"
|
||||
@pointerleave="outputHovered = undefined"
|
||||
@pointerdown.stop.prevent="handlePortClick(port.portId)"
|
||||
/>
|
||||
<rect class="outputPort" />
|
||||
</g>
|
||||
<text class="outputPortLabel">{{ port.label }}</text>
|
||||
</g>
|
||||
</template>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@ -310,7 +356,14 @@ const handlePortClick = useDoubleClick(
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.outputTypeName {
|
||||
.portClip {
|
||||
clip-path: inset(
|
||||
0 calc((1 - var(--port-clip-end)) * (100% + 1px) - 0.5px) 0
|
||||
calc(var(--port-clip-start) * (100% + 1px) + 0.5px)
|
||||
);
|
||||
}
|
||||
|
||||
.outputPortLabel {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
@ -421,6 +474,7 @@ const handlePortClick = useDoubleClick(
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.GraphNode .selection:hover + .binding,
|
||||
|
@ -19,7 +19,7 @@ const selection = injectGraphSelection(true)
|
||||
const navigator = injectGraphNavigator(true)
|
||||
|
||||
const emit = defineEmits<{
|
||||
nodeOutputPortDoubleClick: [nodeId: ExprId]
|
||||
nodeOutputPortDoubleClick: [portId: ExprId]
|
||||
}>()
|
||||
|
||||
function updateNodeContent(id: ExprId, updates: [ContentRange, string][]) {
|
||||
@ -55,7 +55,7 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
|
||||
:edited="id === graphStore.editedNodeInfo?.id"
|
||||
@update:edited="graphStore.setEditedNode(id, $event)"
|
||||
@updateRect="graphStore.updateNodeRect(id, $event)"
|
||||
@delete="graphStore.deleteNode(id)"
|
||||
@delete="graphStore.deleteNode"
|
||||
@pointerenter="hoverNode(id)"
|
||||
@pointerleave="hoverNode(undefined)"
|
||||
@updateContent="updateNodeContent(id, $event)"
|
||||
@ -63,8 +63,8 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
|
||||
@setVisualizationVisible="graphStore.setNodeVisualizationVisible(id, $event)"
|
||||
@dragging="nodeIsDragged(id, $event)"
|
||||
@draggingCommited="dragging.finishDrag()"
|
||||
@outputPortClick="graphStore.createEdgeFromOutput(id)"
|
||||
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', id)"
|
||||
@outputPortClick="graphStore.createEdgeFromOutput"
|
||||
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', $event)"
|
||||
/>
|
||||
<UploadingFile
|
||||
v-for="(nameAndFile, index) in uploadingFiles"
|
||||
|
@ -5,9 +5,13 @@ import {
|
||||
type WidgetInput,
|
||||
} from '@/providers/widgetRegistry'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import { injectWidgetUsageInfo, provideWidgetUsageInfo } from '@/providers/widgetUsageInfo'
|
||||
import {
|
||||
injectWidgetUsageInfo,
|
||||
provideWidgetUsageInfo,
|
||||
usageKeyForInput,
|
||||
} from '@/providers/widgetUsageInfo'
|
||||
import { AstExtended } from '@/util/ast'
|
||||
import { computed, proxyRefs, ref, toRef } from 'vue'
|
||||
import { computed, proxyRefs, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ input: WidgetInput; nest?: boolean }>()
|
||||
defineOptions({
|
||||
@ -17,8 +21,11 @@ defineOptions({
|
||||
const registry = injectWidgetRegistry()
|
||||
const tree = injectWidgetTree()
|
||||
const parentUsageInfo = injectWidgetUsageInfo(true)
|
||||
const usageKey = computed(() => usageKeyForInput(props.input))
|
||||
const sameInputAsParent = computed(() => parentUsageInfo?.usageKey === usageKey.value)
|
||||
|
||||
const whitespace = computed(() =>
|
||||
parentUsageInfo?.input !== props.input && props.input instanceof AstExtended
|
||||
!sameInputAsParent.value && props.input instanceof AstExtended
|
||||
? ' '.repeat(props.input.whitespaceLength() ?? 0)
|
||||
: '',
|
||||
)
|
||||
@ -26,7 +33,7 @@ const whitespace = computed(() =>
|
||||
// TODO: Fetch dynamic widget config from engine. [#8260]
|
||||
const dynamicConfig = ref<WidgetConfiguration>()
|
||||
const sameInputParentWidgets = computed(() =>
|
||||
parentUsageInfo?.input === props.input ? parentUsageInfo?.previouslyUsed : undefined,
|
||||
sameInputAsParent.value ? parentUsageInfo?.previouslyUsed : undefined,
|
||||
)
|
||||
const nesting = computed(() => (parentUsageInfo?.nesting ?? 0) + (props.nest === true ? 1 : 0))
|
||||
|
||||
@ -42,13 +49,19 @@ const selectedWidget = computed(() => {
|
||||
})
|
||||
provideWidgetUsageInfo(
|
||||
proxyRefs({
|
||||
input: toRef(props, 'input'),
|
||||
usageKey,
|
||||
nesting,
|
||||
previouslyUsed: computed(() => {
|
||||
const nextSameNodeWidgets = new Set(sameInputParentWidgets.value)
|
||||
if (selectedWidget.value != null) nextSameNodeWidgets.add(selectedWidget.value)
|
||||
if (selectedWidget.value != null) {
|
||||
nextSameNodeWidgets.add(selectedWidget.value.default)
|
||||
if (selectedWidget.value.widgetDefinition.prevent) {
|
||||
for (const prevented of selectedWidget.value.widgetDefinition.prevent)
|
||||
nextSameNodeWidgets.add(prevented)
|
||||
}
|
||||
}
|
||||
return nextSameNodeWidgets
|
||||
}),
|
||||
nesting,
|
||||
}),
|
||||
)
|
||||
const spanStart = computed(() => {
|
||||
@ -60,7 +73,7 @@ const spanStart = computed(() => {
|
||||
<template>
|
||||
{{ whitespace
|
||||
}}<component
|
||||
:is="selectedWidget"
|
||||
:is="selectedWidget.default"
|
||||
v-if="selectedWidget"
|
||||
ref="rootNode"
|
||||
:input="props.input"
|
||||
@ -69,4 +82,11 @@ const spanStart = computed(() => {
|
||||
:data-span-start="spanStart"
|
||||
:data-nesting="nesting"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
:title="`No matching widget for input: ${
|
||||
Object.getPrototypeOf(props.input)?.constructor?.name ?? JSON.stringify(props.input)
|
||||
}`"
|
||||
>🚫</span
|
||||
>
|
||||
</template>
|
||||
|
@ -1,11 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { Tree } from '@/generated/ast'
|
||||
import { ForcePort } from '@/providers/portInfo'
|
||||
import { provideWidgetTree } from '@/providers/widgetTree'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { useTransitioning } from '@/util/animation'
|
||||
import type { AstExtended } from '@/util/ast'
|
||||
import { toRef } from 'vue'
|
||||
import { computed, toRef } from 'vue'
|
||||
import NodeWidget from './NodeWidget.vue'
|
||||
|
||||
const props = defineProps<{ ast: AstExtended }>()
|
||||
const graph = useGraphStore()
|
||||
const rootPort = computed(() => {
|
||||
return props.ast.isTree(Tree.Type.Ident) && !graph.db.isKnownFunctionCall(props.ast.astId)
|
||||
? new ForcePort(props.ast)
|
||||
: props.ast
|
||||
})
|
||||
|
||||
const observedLayoutTransitions = new Set([
|
||||
'margin-left',
|
||||
@ -26,7 +35,7 @@ provideWidgetTree(toRef(props, 'ast'), layoutTransitions.active)
|
||||
|
||||
<template>
|
||||
<span class="NodeWidgetTree" spellcheck="false" v-on="layoutTransitions.events">
|
||||
<NodeWidget :input="ast" />
|
||||
<NodeWidget :input="rootPort" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import { ForcePort } from '@/providers/portInfo'
|
||||
import { defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { AstExtended } from '@/util/ast'
|
||||
import { ArgumentApplication } from '@/util/callTree'
|
||||
import { computed } from 'vue'
|
||||
import { ForcePort } from './WidgetPort.vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
const targetMaybePort = computed(() =>
|
||||
|
@ -1,24 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import { Tree } from '@/generated/ast'
|
||||
import { defineWidget, Score, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { injectFunctionInfo, provideFunctionInfo } from '@/providers/functionInfo'
|
||||
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { AstExtended } from '@/util/ast'
|
||||
import { ArgumentApplication } from '@/util/callTree'
|
||||
import { computed } from 'vue'
|
||||
import { computed, proxyRefs } from 'vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
|
||||
const graph = useGraphStore()
|
||||
|
||||
provideFunctionInfo(
|
||||
proxyRefs({
|
||||
callId: computed(() => props.input.astId),
|
||||
}),
|
||||
)
|
||||
|
||||
const application = computed(() => {
|
||||
const astId = props.input.astId
|
||||
if (astId == null) return props.input
|
||||
const info = graph.db.getMethodCallInfo(astId)
|
||||
return ArgumentApplication.FromAstWithInfo(
|
||||
props.input,
|
||||
info?.suggestion.arguments,
|
||||
info?.methodCall.notAppliedArguments ?? [],
|
||||
const interpreted = ArgumentApplication.Interpret(props.input, info == null)
|
||||
|
||||
const noArgsCall =
|
||||
interpreted.kind === 'prefix' ? graph.db.getMethodCall(interpreted.func.astId) : undefined
|
||||
|
||||
return ArgumentApplication.FromInterpretedWithInfo(
|
||||
interpreted,
|
||||
noArgsCall,
|
||||
info?.methodCall,
|
||||
info?.suggestion,
|
||||
!info?.staticallyApplied,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@ -26,11 +40,29 @@ const application = computed(() => {
|
||||
export const widgetDefinition = defineWidget(
|
||||
AstExtended.isTree([Tree.Type.App, Tree.Type.NamedApp, Tree.Type.Ident, Tree.Type.OprApp]),
|
||||
{
|
||||
priority: 8,
|
||||
priority: -10,
|
||||
score: (props, db) => {
|
||||
const ast = props.input
|
||||
if (ast.astId == null) return Score.Mismatch
|
||||
const prevFunctionState = injectFunctionInfo(true)
|
||||
|
||||
// It is possible to try to render the same function application twice, e.g. when detected an
|
||||
// application with no arguments applied yet, but the application target is also an infix call.
|
||||
// In that case, the reentrant call method info must be ignored to not create an infinite loop,
|
||||
// and to resolve the infix call as its own application.
|
||||
if (prevFunctionState?.callId === ast.astId) return Score.Mismatch
|
||||
|
||||
if (ast.isTree([Tree.Type.App, Tree.Type.NamedApp, Tree.Type.OprApp])) return Score.Perfect
|
||||
return ast.astId && db.isMethodCall(ast.astId) ? Score.Perfect : Score.Mismatch
|
||||
|
||||
const info = db.getMethodCallInfo(ast.astId)
|
||||
if (
|
||||
prevFunctionState != null &&
|
||||
info?.staticallyApplied === true &&
|
||||
props.input.isTree(Tree.Type.Ident)
|
||||
) {
|
||||
return Score.Mismatch
|
||||
}
|
||||
return info != null ? Score.Perfect : Score.Mismatch
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -2,7 +2,7 @@
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import { injectPortInfo, providePortInfo } from '@/providers/portInfo'
|
||||
import { ForcePort, injectPortInfo, providePortInfo } from '@/providers/portInfo'
|
||||
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
@ -122,17 +122,6 @@ const innerWidget = computed(() => {
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export class ForcePort {
|
||||
constructor(public ast: AstExtended) {}
|
||||
}
|
||||
|
||||
declare const ForcePortKey: unique symbol
|
||||
declare module '@/providers/widgetRegistry' {
|
||||
interface WidgetInputTypes {
|
||||
[ForcePortKey]: ForcePort
|
||||
}
|
||||
}
|
||||
|
||||
export const widgetDefinition = defineWidget(
|
||||
[
|
||||
ForcePort,
|
||||
|
@ -10,7 +10,7 @@ const props = defineProps(widgetProps(widgetDefinition))
|
||||
export const widgetDefinition = defineWidget([ArgumentAst, ArgumentPlaceholder], {
|
||||
priority: -1,
|
||||
score: (props) =>
|
||||
props.nesting < 2 && props.input.kind == ApplicationKind.Prefix
|
||||
props.nesting < 2 && props.input.kind === ApplicationKind.Prefix
|
||||
? Score.Perfect
|
||||
: Score.Mismatch,
|
||||
})
|
||||
|
@ -1,19 +1,22 @@
|
||||
export function useDoubleClick(onClick: Function, onDoubleClick: Function) {
|
||||
export function useDoubleClick<Args extends any[]>(
|
||||
onClick: (...args: Args) => void,
|
||||
onDoubleClick: (...args: Args) => void,
|
||||
) {
|
||||
const timeBetweenClicks = 200
|
||||
let clickCount = 0
|
||||
let singleClickTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
const handleClick = () => {
|
||||
const handleClick = (...args: Args) => {
|
||||
clickCount++
|
||||
if (clickCount === 1) {
|
||||
onClick()
|
||||
onClick(...args)
|
||||
singleClickTimer = setTimeout(() => {
|
||||
clickCount = 0
|
||||
}, timeBetweenClicks)
|
||||
} else if (clickCount === 2) {
|
||||
clearTimeout(singleClickTimer)
|
||||
clickCount = 0
|
||||
onDoubleClick()
|
||||
onDoubleClick(...args)
|
||||
}
|
||||
}
|
||||
return { handleClick }
|
||||
|
@ -77,8 +77,8 @@ describe('WidgetRegistry', () => {
|
||||
test('selects a widget based on the input type', () => {
|
||||
const forAst = registry.select({ input: someAst, config: undefined, nesting: 0 })
|
||||
const forArg = registry.select({ input: somePlaceholder, config: undefined, nesting: 0 })
|
||||
expect(forAst).toStrictEqual(widgetA.default)
|
||||
expect(forArg).toStrictEqual(widgetB.default)
|
||||
expect(forAst).toStrictEqual(widgetA)
|
||||
expect(forArg).toStrictEqual(widgetB)
|
||||
})
|
||||
|
||||
test('selects a widget outside of the excluded set', () => {
|
||||
@ -90,8 +90,8 @@ describe('WidgetRegistry', () => {
|
||||
{ input: somePlaceholder, config: undefined, nesting: 0 },
|
||||
new Set([widgetB.default]),
|
||||
)
|
||||
expect(forAst).toStrictEqual(widgetC.default)
|
||||
expect(forArg).toStrictEqual(widgetC.default)
|
||||
expect(forAst).toStrictEqual(widgetC)
|
||||
expect(forArg).toStrictEqual(widgetC)
|
||||
})
|
||||
|
||||
test('returns undefined when all options are exhausted', () => {
|
||||
@ -111,7 +111,7 @@ describe('WidgetRegistry', () => {
|
||||
{ input: blankAst, config: undefined, nesting: 0 },
|
||||
new Set([widgetA.default, widgetD.default]),
|
||||
)
|
||||
expect(selectedFirst).toStrictEqual(widgetD.default)
|
||||
expect(selectedNext).toStrictEqual(widgetC.default)
|
||||
expect(selectedFirst).toStrictEqual(widgetD)
|
||||
expect(selectedNext).toStrictEqual(widgetC)
|
||||
})
|
||||
})
|
||||
|
10
app/gui2/src/providers/functionInfo.ts
Normal file
10
app/gui2/src/providers/functionInfo.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { identity } from '@vueuse/core'
|
||||
import type { ExprId } from 'shared/yjsModel'
|
||||
import { createContextStore } from '.'
|
||||
|
||||
interface FunctionInfo {
|
||||
callId: ExprId | undefined
|
||||
}
|
||||
|
||||
export { injectFn as injectFunctionInfo, provideFn as provideFunctionInfo }
|
||||
const { provideFn, injectFn } = createContextStore('Function info', identity<FunctionInfo>)
|
@ -1,5 +1,7 @@
|
||||
import type { AstExtended } from '@/util/ast'
|
||||
import { identity } from '@vueuse/core'
|
||||
import { createContextStore } from '.'
|
||||
import { GetUsageKey } from './widgetUsageInfo'
|
||||
|
||||
interface PortInfo {
|
||||
portId: string
|
||||
@ -8,3 +10,16 @@ interface PortInfo {
|
||||
|
||||
export { injectFn as injectPortInfo, provideFn as providePortInfo }
|
||||
const { provideFn, injectFn } = createContextStore('Port info', identity<PortInfo>)
|
||||
|
||||
/**
|
||||
* Widget input type that can be used to force a specific AST to be rendered as a port widget,
|
||||
* even if it wouldn't normally be rendered as such.
|
||||
*/
|
||||
export class ForcePort {
|
||||
constructor(public ast: AstExtended) {
|
||||
if (ast instanceof ForcePort) throw new Error('ForcePort cannot be nested')
|
||||
}
|
||||
[GetUsageKey]() {
|
||||
return this.ast
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ export enum Score {
|
||||
|
||||
export interface WidgetProps<T> {
|
||||
input: T
|
||||
config: WidgetConfiguration | undefined
|
||||
config?: WidgetConfiguration | undefined
|
||||
nesting: number
|
||||
}
|
||||
|
||||
@ -111,6 +111,9 @@ export interface WidgetOptions<T extends WidgetInput> {
|
||||
* pass the input filter.
|
||||
*/
|
||||
score?: ((info: WidgetProps<T>, db: GraphDb) => Score) | Score
|
||||
// A list of widget kinds that will be prevented from being used on the same node as this widget,
|
||||
// once this widget is used.
|
||||
prevent?: WidgetComponent<any>[]
|
||||
}
|
||||
|
||||
export interface WidgetDefinition<T extends WidgetInput> {
|
||||
@ -133,6 +136,7 @@ export interface WidgetDefinition<T extends WidgetInput> {
|
||||
* filter. When not provided, the widget will be considered a perfect match for all inputs that
|
||||
*/
|
||||
score: (props: WidgetProps<T>, db: GraphDb) => Score
|
||||
prevent: WidgetComponent<any>[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@ -195,6 +199,7 @@ export function defineWidget<M extends InputMatcher<any> | InputMatcher<any>[]>(
|
||||
priority: definition.priority,
|
||||
match: makeInputMatcher<InputTy<M>>(matchInputs),
|
||||
score,
|
||||
prevent: definition.prevent,
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,15 +237,22 @@ export class WidgetRegistry {
|
||||
|
||||
loadBuiltins() {
|
||||
const bulitinWidgets = import.meta.glob('@/components/GraphEditor/widgets/*.vue')
|
||||
for (const [path, asyncModule] of Object.entries(bulitinWidgets)) {
|
||||
this.loadAndCheckWidgetModule(asyncModule(), path)
|
||||
}
|
||||
this.loadAndCheckWidgetModules(Object.entries(bulitinWidgets))
|
||||
}
|
||||
|
||||
async loadAndCheckWidgetModule(asyncModule: Promise<unknown>, path: string) {
|
||||
const m = await asyncModule
|
||||
if (isWidgetModule(m)) this.registerWidgetModule(m)
|
||||
else console.error('Invalid widget module:', path, m)
|
||||
async loadAndCheckWidgetModules(
|
||||
asyncModules: [path: string, asyncModule: () => Promise<unknown>][],
|
||||
) {
|
||||
const modules = await Promise.allSettled(
|
||||
asyncModules.map(([path, mod]) => mod().then((m) => [path, m] as const)),
|
||||
)
|
||||
for (const result of modules) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const [path, mod] = result.value
|
||||
if (isWidgetModule(mod)) this.registerWidgetModule(mod)
|
||||
else console.error('Invalid widget module:', path, mod)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -261,26 +273,26 @@ export class WidgetRegistry {
|
||||
select<T extends WidgetInput>(
|
||||
props: WidgetProps<T>,
|
||||
alreadyUsed?: Set<WidgetComponent<any>>,
|
||||
): WidgetComponent<T> | undefined {
|
||||
): WidgetModule<T> | undefined {
|
||||
// The type and score of the best widget found so far.
|
||||
let best: WidgetComponent<T> | undefined = undefined
|
||||
let best: WidgetModule<T> | undefined = undefined
|
||||
let bestScore = Score.Mismatch
|
||||
|
||||
// Iterate over all loaded widget kinds in order of decreasing priority.
|
||||
for (const module of this.sortedModules.value) {
|
||||
for (const widgetModule of this.sortedModules.value) {
|
||||
// Skip matching widgets that are declared as already used.
|
||||
if (alreadyUsed && alreadyUsed.has(module.default)) continue
|
||||
if (alreadyUsed && alreadyUsed.has(widgetModule.default)) continue
|
||||
|
||||
// Skip widgets that don't match the input type.
|
||||
if (!module.widgetDefinition.match(props.input)) continue
|
||||
if (!widgetModule.widgetDefinition.match(props.input)) continue
|
||||
|
||||
// Perform a match and update the best widget if the match is better than the previous one.
|
||||
const score = module.widgetDefinition.score(props, this.db)
|
||||
const score = widgetModule.widgetDefinition.score(props, this.db)
|
||||
// If we found a perfect match, we can return immediately, as there can be no better match.
|
||||
if (score === Score.Perfect) return module.default
|
||||
if (score === Score.Perfect) return widgetModule
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = module.default
|
||||
best = widgetModule
|
||||
}
|
||||
}
|
||||
// Once we've checked all widgets, return the best match found, if any.
|
||||
|
@ -11,8 +11,31 @@ const { provideFn, injectFn } = createContextStore('Widget usage info', identity
|
||||
* AST node.
|
||||
*/
|
||||
interface WidgetUsageInfo {
|
||||
input: WidgetInput
|
||||
/**
|
||||
* An object which is used to distinguish between distinct nodes in a widget tree. When selecting
|
||||
* a widget type for an input value with the same `usageKey` as in parent widget, the widget types
|
||||
* that were previously used for this input value are not considered for selection. The key is
|
||||
* determined by the widget input's method defined on {@link GetUsageKey} symbol key. When no such
|
||||
* method is defined, the input value itself is used as the key.
|
||||
*/
|
||||
usageKey: unknown
|
||||
/** All widget types that were rendered so far using the same AST node. */
|
||||
previouslyUsed: Set<WidgetComponent<any>>
|
||||
nesting: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A symbol key used for defining a widget input method's usage key. A method with this key can be
|
||||
* declared for widget input types that are not unique by themselves, but are just a thin wrapper
|
||||
* around another input value, and don't want to be considered as a completely separate entity for
|
||||
* the purposes of widget type selection.
|
||||
*/
|
||||
export const GetUsageKey = Symbol('GetUsageKey')
|
||||
|
||||
export function usageKeyForInput(widget: WidgetInput): unknown {
|
||||
if (GetUsageKey in widget && typeof widget[GetUsageKey] === 'function') {
|
||||
return widget[GetUsageKey]()
|
||||
} else {
|
||||
return widget
|
||||
}
|
||||
}
|
||||
|
@ -54,14 +54,14 @@ test('Reading graph from definition', () => {
|
||||
expect(db.getIdentDefiningNode('node1')).toBe(id04)
|
||||
expect(db.getIdentDefiningNode('node2')).toBe(id08)
|
||||
expect(db.getIdentDefiningNode('function')).toBeUndefined()
|
||||
expect(db.getNodeMainOutputPortIdentifier(id04)).toBe('node1')
|
||||
expect(db.getNodeMainOutputPortIdentifier(id08)).toBe('node2')
|
||||
expect(db.getNodeMainOutputPortIdentifier(id03)).toBeUndefined()
|
||||
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(id04))).toBe('node1')
|
||||
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(id08))).toBe('node2')
|
||||
expect(db.getOutputPortIdentifier(db.getNodeFirstOutputPort(id03))).toBe('node1')
|
||||
|
||||
// Commented the connection from input node, as we don't support them yet.
|
||||
expect(Array.from(db.connections.allForward(), ([key]) => key)).toEqual([id03])
|
||||
// expect(Array.from(db.connections.lookup(id02))).toEqual([id05])
|
||||
expect(Array.from(db.connections.lookup(id03))).toEqual([id09])
|
||||
// expect(db.getIdentifierOfConnection(id02)).toBe('a')
|
||||
expect(db.getIdentifierOfConnection(id03)).toBe('node1')
|
||||
// expect(db.getOutputPortIdentifier(id02)).toBe('a')
|
||||
expect(db.getOutputPortIdentifier(id03)).toBe('node1')
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDatabase'
|
||||
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
||||
import { byteArraysEqual, tryGetIndex } from '@/util/array'
|
||||
import { arrayEquals, byteArraysEqual, tryGetIndex } from '@/util/array'
|
||||
import { Ast, AstExtended } from '@/util/ast'
|
||||
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
|
||||
import { colorFromString } from '@/util/colors'
|
||||
@ -10,7 +10,7 @@ import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reac
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import * as set from 'lib0/set'
|
||||
import type { MethodCall } from 'shared/languageServerTypes'
|
||||
import { methodPointerEquals, type MethodCall } from 'shared/languageServerTypes'
|
||||
import {
|
||||
IdMap,
|
||||
visMetadataEquals,
|
||||
@ -150,17 +150,18 @@ export class GraphDb {
|
||||
return Array.from(allTargets(this))
|
||||
})
|
||||
|
||||
/** First output port of the node.
|
||||
*
|
||||
* When the node will be marked as source node for a new one (i.e. the node will be selected
|
||||
* when adding), the resulting connection's source will be the main port.
|
||||
*/
|
||||
nodeMainOutputPort = new ReactiveIndex(this.nodeIdToNode, (id, entry) => {
|
||||
/** Output port bindings of the node. Lists all bindings that can be dragged out from a node. */
|
||||
nodeOutputPorts = new ReactiveIndex(this.nodeIdToNode, (id, entry) => {
|
||||
if (entry.pattern == null) return []
|
||||
for (const ast of entry.pattern.walkRecursive()) {
|
||||
if (this.bindings.bindings.has(ast.astId)) return [[id, ast.astId]]
|
||||
}
|
||||
return []
|
||||
const ports = new Set<ExprId>()
|
||||
entry.pattern.visitRecursive((ast) => {
|
||||
if (this.bindings.bindings.has(ast.astId)) {
|
||||
ports.add(ast.astId)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return Array.from(ports, (port) => [id, port])
|
||||
})
|
||||
|
||||
nodeMainSuggestion = new ReactiveMapping(this.nodeIdToNode, (id, _entry) => {
|
||||
@ -182,9 +183,8 @@ export class GraphDb {
|
||||
return groupColorStyle(group)
|
||||
})
|
||||
|
||||
getNodeMainOutputPortIdentifier(id: ExprId): string | undefined {
|
||||
const mainPort = set.first(this.nodeMainOutputPort.lookup(id))
|
||||
return mainPort != null ? this.bindings.bindings.get(mainPort)?.identifier : undefined
|
||||
getNodeFirstOutputPort(id: ExprId): ExprId {
|
||||
return set.first(this.nodeOutputPorts.lookup(id)) ?? id
|
||||
}
|
||||
|
||||
getExpressionNodeId(exprId: ExprId | undefined): ExprId | undefined {
|
||||
@ -204,7 +204,7 @@ export class GraphDb {
|
||||
return this.valuesRegistry.getExpressionInfo(id)
|
||||
}
|
||||
|
||||
getIdentifierOfConnection(source: ExprId): string | undefined {
|
||||
getOutputPortIdentifier(source: ExprId): string | undefined {
|
||||
return this.bindings.bindings.get(source)?.identifier
|
||||
}
|
||||
|
||||
@ -212,20 +212,35 @@ export class GraphDb {
|
||||
return this.bindings.identifierToBindingId.hasKey(ident)
|
||||
}
|
||||
|
||||
isMethodCall(id: ExprId): boolean {
|
||||
return this.getExpressionInfo(id)?.methodCall != null
|
||||
isKnownFunctionCall(id: ExprId): boolean {
|
||||
return this.getMethodCallInfo(id) != null
|
||||
}
|
||||
|
||||
getMethodCall(id: ExprId): MethodCall | undefined {
|
||||
const info = this.getExpressionInfo(id)
|
||||
if (info == null) return
|
||||
return (
|
||||
info.methodCall ?? (info.payload.type === 'Value' ? info.payload.functionSchema : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
getMethodCallInfo(
|
||||
id: ExprId,
|
||||
): { methodCall: MethodCall; suggestion: SuggestionEntry } | undefined {
|
||||
const methodCall = this.getExpressionInfo(id)?.methodCall
|
||||
):
|
||||
| { methodCall: MethodCall; suggestion: SuggestionEntry; staticallyApplied: boolean }
|
||||
| undefined {
|
||||
const info = this.getExpressionInfo(id)
|
||||
if (info == null) return
|
||||
const payloadFuncSchema =
|
||||
info.payload.type === 'Value' ? info.payload.functionSchema : undefined
|
||||
const methodCall = info.methodCall ?? payloadFuncSchema
|
||||
if (methodCall == null) return
|
||||
const suggestionId = this.suggestionDb.findByMethodPointer(methodCall.methodPointer)
|
||||
if (suggestionId == null) return
|
||||
const suggestion = this.suggestionDb.get(suggestionId)
|
||||
if (suggestion == null) return
|
||||
return { methodCall, suggestion }
|
||||
const staticallyApplied = mathodCallEquals(methodCall, payloadFuncSchema)
|
||||
return { methodCall, suggestion, staticallyApplied }
|
||||
}
|
||||
|
||||
getNodeColorStyle(id: ExprId): string {
|
||||
@ -345,3 +360,13 @@ function* getFunctionNodeExpressions(func: Ast.Tree.Function): Generator<Ast.Tre
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mathodCallEquals(a: MethodCall | undefined, b: MethodCall | undefined): boolean {
|
||||
return (
|
||||
a === b ||
|
||||
(a != null &&
|
||||
b != null &&
|
||||
methodPointerEquals(a.methodPointer, b.methodPointer) &&
|
||||
arrayEquals(a.notAppliedArguments, b.notAppliedArguments))
|
||||
)
|
||||
}
|
||||
|
@ -219,9 +219,9 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
// Create a node from a source expression, and insert it into the graph. The return value will be
|
||||
// the new node's ID, or `null` if the node creation fails.
|
||||
function createNodeFromSource(position: Vec2, source: ExprId): Opt<ExprId> {
|
||||
const sourceNodeName = db.getNodeMainOutputPortIdentifier(source)
|
||||
const sourceNodeNameWithDot = sourceNodeName ? sourceNodeName + '.' : ''
|
||||
return createNode(position, sourceNodeNameWithDot)
|
||||
const sourcePortName = db.getOutputPortIdentifier(source)
|
||||
const sourcePortNameWithDot = sourcePortName ? sourcePortName + '.' : ''
|
||||
return createNode(position, sourcePortNameWithDot)
|
||||
}
|
||||
|
||||
function deleteNode(id: ExprId) {
|
||||
|
@ -38,7 +38,7 @@ export function partitionPoint<T>(
|
||||
}
|
||||
|
||||
/** Index into an array using specified index. When the index is nullable, returns undefined. */
|
||||
export function tryGetIndex<T>(arr: Opt<T[]>, index: Opt<number>): T | undefined {
|
||||
export function tryGetIndex<T>(arr: Opt<readonly T[]>, index: Opt<number>): T | undefined {
|
||||
return index == null ? undefined : arr?.[index]
|
||||
}
|
||||
|
||||
@ -49,3 +49,7 @@ export function tryGetIndex<T>(arr: Opt<T[]>, index: Opt<number>): T | undefined
|
||||
export function byteArraysEqual(a: Opt<Uint8Array>, b: Opt<Uint8Array>): boolean {
|
||||
return a === b || (a != null && b != null && indexedDB.cmp(a, b) === 0)
|
||||
}
|
||||
|
||||
export function arrayEquals<T>(a: T[], b: T[]): boolean {
|
||||
return a === b || (a.length === b.length && a.every((v, i) => v === b[i]))
|
||||
}
|
||||
|
@ -1,23 +1,39 @@
|
||||
import { Tree } from '@/generated/ast'
|
||||
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
||||
import { AstExtended } from '@/util/ast'
|
||||
import { ArgumentApplication, ArgumentPlaceholder } from '@/util/callTree'
|
||||
import { isSome } from '@/util/opt'
|
||||
import type { SuggestionEntryArgument } from 'shared/languageServerTypes/suggestions'
|
||||
import { type Identifier, type QualifiedName } from '@/util/qualifiedName'
|
||||
import type { MethodCall } from 'shared/languageServerTypes'
|
||||
import { IdMap } from 'shared/yjsModel'
|
||||
import { assert, expect, test } from 'vitest'
|
||||
|
||||
const knownArguments: SuggestionEntryArgument[] = [
|
||||
{ name: 'a', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
{ name: 'b', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
{ name: 'c', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
{ name: 'd', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
]
|
||||
const mockSuggestion: SuggestionEntry = {
|
||||
kind: SuggestionKind.Method,
|
||||
name: 'func' as Identifier,
|
||||
definedIn: 'Foo.Bar' as QualifiedName,
|
||||
selfType: 'Foo.Bar',
|
||||
returnType: 'Any',
|
||||
arguments: [
|
||||
{ name: 'self', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
{ name: 'a', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
{ name: 'b', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
{ name: 'c', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
{ name: 'd', type: 'Any', isSuspended: false, hasDefault: false },
|
||||
],
|
||||
documentation: [],
|
||||
isPrivate: false,
|
||||
isUnstable: false,
|
||||
aliases: [],
|
||||
}
|
||||
|
||||
function testArgs(paddedExpression: string, pattern: string) {
|
||||
const expression = paddedExpression.trim()
|
||||
const notAppliedArguments = pattern
|
||||
.split(' ')
|
||||
.map((p) => (p.startsWith('?') ? knownArguments.findIndex((k) => p.endsWith(k.name)) : null))
|
||||
.map((p) =>
|
||||
p.startsWith('?') ? mockSuggestion.arguments.findIndex((k) => p.slice(1) === k.name) : null,
|
||||
)
|
||||
.filter(isSome)
|
||||
|
||||
test(`argument list: ${paddedExpression} ${pattern}`, () => {
|
||||
@ -30,7 +46,31 @@ function testArgs(paddedExpression: string, pattern: string) {
|
||||
return first.value.expression
|
||||
})
|
||||
|
||||
const call = ArgumentApplication.FromAstWithInfo(ast, knownArguments, notAppliedArguments)
|
||||
const methodCall: MethodCall = {
|
||||
methodPointer: {
|
||||
name: 'func',
|
||||
definedOnType: 'Foo.Bar',
|
||||
module: 'Foo.Bar',
|
||||
},
|
||||
notAppliedArguments,
|
||||
}
|
||||
|
||||
const funcMethodCall: MethodCall = {
|
||||
methodPointer: {
|
||||
name: 'func',
|
||||
definedOnType: 'Foo.Bar',
|
||||
module: 'Foo.Bar',
|
||||
},
|
||||
notAppliedArguments: [1, 2, 3, 4],
|
||||
}
|
||||
|
||||
const interpreted = ArgumentApplication.Interpret(ast, false)
|
||||
const call = ArgumentApplication.FromInterpretedWithInfo(
|
||||
interpreted,
|
||||
funcMethodCall,
|
||||
methodCall,
|
||||
mockSuggestion,
|
||||
)
|
||||
assert(call instanceof ArgumentApplication)
|
||||
expect(printArgPattern(call)).toEqual(pattern)
|
||||
})
|
||||
@ -46,7 +86,8 @@ function printArgPattern(application: ArgumentApplication | AstExtended<Tree>) {
|
||||
: current.appTree?.isTree(Tree.Type.NamedApp)
|
||||
? '='
|
||||
: '@'
|
||||
parts.push(sigil + (current.argument.info?.name ?? '_'))
|
||||
const argInfo = 'info' in current.argument ? current.argument.info : undefined
|
||||
parts.push(sigil + (argInfo?.name ?? '_'))
|
||||
current = current.target
|
||||
}
|
||||
return parts.reverse().join(' ')
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Token, Tree } from '@/generated/ast'
|
||||
import type { SuggestionEntryArgument } from '@/stores/suggestionDatabase/entry'
|
||||
import type { ForcePort } from '@/providers/portInfo'
|
||||
import type { SuggestionEntry, SuggestionEntryArgument } from '@/stores/suggestionDatabase/entry'
|
||||
import type { MethodCall } from 'shared/languageServerTypes'
|
||||
import { tryGetIndex } from './array'
|
||||
import type { AstExtended } from './ast'
|
||||
|
||||
@ -29,63 +31,120 @@ export class ArgumentAst {
|
||||
) {}
|
||||
}
|
||||
|
||||
type InterpretedCall = AnalyzedInfix | AnalyzedPrefix
|
||||
|
||||
interface AnalyzedInfix {
|
||||
kind: 'infix'
|
||||
appTree: AstExtended<Tree.OprApp>
|
||||
operator: AstExtended<Token.Operator> | undefined
|
||||
lhs: AstExtended<Tree> | undefined
|
||||
rhs: AstExtended<Tree> | undefined
|
||||
}
|
||||
|
||||
interface AnalyzedPrefix {
|
||||
kind: 'prefix'
|
||||
func: AstExtended<Tree>
|
||||
args: FoundApplication[]
|
||||
}
|
||||
|
||||
interface FoundApplication {
|
||||
appTree: AstExtended<Tree.App | Tree.NamedApp>
|
||||
argument: AstExtended<Tree>
|
||||
argName: string | undefined
|
||||
}
|
||||
|
||||
export class ArgumentApplication {
|
||||
private constructor(
|
||||
public appTree: AstExtended<Tree> | undefined,
|
||||
public target: ArgumentApplication | AstExtended<Tree> | ArgumentPlaceholder | ArgumentAst,
|
||||
public infixOperator: AstExtended<Token.Operator> | undefined,
|
||||
public argument: ArgumentAst | ArgumentPlaceholder,
|
||||
public argument: AstExtended<Tree> | ArgumentAst | ArgumentPlaceholder,
|
||||
) {}
|
||||
|
||||
static FromAstWithInfo(
|
||||
callRoot: AstExtended<Tree>,
|
||||
knownArguments: SuggestionEntryArgument[] | undefined,
|
||||
notAppliedArguments: number[],
|
||||
): ArgumentApplication | AstExtended<Tree> {
|
||||
interface FoundApplication {
|
||||
appTree: AstExtended<Tree.App | Tree.NamedApp>
|
||||
argument: AstExtended<Tree>
|
||||
argName: string | undefined
|
||||
static Interpret(callRoot: AstExtended<Tree>, allowInterpretAsInfix: boolean): InterpretedCall {
|
||||
if (allowInterpretAsInfix && callRoot.isTree([Tree.Type.OprApp])) {
|
||||
// Infix chains are handled one level at a time. Each application may have at most 2 arguments.
|
||||
return {
|
||||
kind: 'infix',
|
||||
appTree: callRoot,
|
||||
operator: callRoot.tryMap((t) => (t.opr.ok ? t.opr.value : undefined)),
|
||||
lhs: callRoot.tryMap((t) => t.lhs),
|
||||
rhs: callRoot.tryMap((t) => t.rhs),
|
||||
}
|
||||
} else {
|
||||
// Prefix chains are handled all at once, as they may have arbitrary number of arguments.
|
||||
const foundApplications: FoundApplication[] = []
|
||||
let nextApplication = callRoot
|
||||
// Traverse the AST and find all arguments applied in sequence to the same function.
|
||||
while (nextApplication.isTree([Tree.Type.App, Tree.Type.NamedApp])) {
|
||||
const argument = nextApplication.map((t) => t.arg)
|
||||
const argName = nextApplication.isTree(Tree.Type.NamedApp)
|
||||
? nextApplication.map((t) => t.name).repr()
|
||||
: undefined
|
||||
foundApplications.push({
|
||||
appTree: nextApplication,
|
||||
argument,
|
||||
argName,
|
||||
})
|
||||
nextApplication = nextApplication.map((t) => t.func)
|
||||
}
|
||||
return {
|
||||
kind: 'prefix',
|
||||
func: nextApplication,
|
||||
// The applications are peeled away from outer to inner, so arguments are in reverse order. We
|
||||
// need to reverse them back to match them with the order in suggestion entry.
|
||||
args: foundApplications.reverse(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infix chains are handled one level at a time. Each application may have at most 2 arguments.
|
||||
if (callRoot.isTree([Tree.Type.OprApp])) {
|
||||
const oprApp = callRoot
|
||||
const infixOperator = callRoot.tryMap((t) => (t.opr.ok ? t.opr.value : undefined))
|
||||
static FromInterpretedWithInfo(
|
||||
interpreted: InterpretedCall,
|
||||
noArgsCall: MethodCall | undefined,
|
||||
appMethodCall: MethodCall | undefined,
|
||||
suggestion: SuggestionEntry | undefined,
|
||||
stripSelfArgument: boolean = false,
|
||||
): ArgumentApplication | AstExtended<Tree> {
|
||||
const knownArguments = suggestion?.arguments
|
||||
|
||||
if (interpreted.kind === 'infix') {
|
||||
const isAccess = isAccessOperator(interpreted.operator)
|
||||
const argFor = (key: 'lhs' | 'rhs', index: number) => {
|
||||
const tree = oprApp.tryMap((t) => t[key])
|
||||
const tree = interpreted[key]
|
||||
const info = tryGetIndex(knownArguments, index) ?? unknownArgInfoNamed(key)
|
||||
return tree != null
|
||||
? new ArgumentAst(tree, 0, info, ApplicationKind.Infix)
|
||||
? isAccess
|
||||
? tree
|
||||
: new ArgumentAst(tree, 0, info, ApplicationKind.Infix)
|
||||
: new ArgumentPlaceholder(0, info, ApplicationKind.Infix)
|
||||
}
|
||||
return new ArgumentApplication(callRoot, argFor('lhs', 0), infixOperator, argFor('rhs', 1))
|
||||
return new ArgumentApplication(
|
||||
interpreted.appTree,
|
||||
argFor('lhs', 0),
|
||||
interpreted.operator,
|
||||
argFor('rhs', 1),
|
||||
)
|
||||
}
|
||||
|
||||
// Prefix chains are handled all at once, as they may have arbitrary number of arguments.
|
||||
const foundApplications: FoundApplication[] = []
|
||||
let nextApplication = callRoot
|
||||
// Traverse the AST and find all arguments applied in sequence to the same function.
|
||||
while (nextApplication.isTree([Tree.Type.App, Tree.Type.NamedApp])) {
|
||||
const argument = nextApplication.map((t) => t.arg)
|
||||
const argName = nextApplication.isTree(Tree.Type.NamedApp)
|
||||
? nextApplication.map((t) => t.name).repr()
|
||||
: undefined
|
||||
foundApplications.push({
|
||||
appTree: nextApplication,
|
||||
argument,
|
||||
argName,
|
||||
})
|
||||
nextApplication = nextApplication.map((t) => t.func)
|
||||
}
|
||||
|
||||
// The applications are peeled away from outer to inner, so arguments are in reverse order. We
|
||||
// need to reverse them back to match them with the order in suggestion entry.
|
||||
const foundArgs = foundApplications.reverse()
|
||||
|
||||
const notAppliedArguments = appMethodCall?.notAppliedArguments ?? []
|
||||
const placeholdersToInsert = notAppliedArguments.slice()
|
||||
const notAppliedSet = new Set(notAppliedArguments)
|
||||
const argumentsLeftToMatch = Array.from(knownArguments ?? [], (_, i) => i).filter(
|
||||
(_, i) => !notAppliedSet.has(i),
|
||||
const allPossiblePrefixArguments = Array.from(knownArguments ?? [], (_, i) => i)
|
||||
|
||||
// when this is a method application with applied 'self', the subject of the access operator is
|
||||
// treated as a 'self' argument.
|
||||
if (
|
||||
stripSelfArgument &&
|
||||
knownArguments?.[0]?.name === 'self' &&
|
||||
getAccessOprSubject(interpreted.func) != null
|
||||
) {
|
||||
allPossiblePrefixArguments.shift()
|
||||
}
|
||||
const notAppliedOriginally = new Set(
|
||||
noArgsCall?.notAppliedArguments ?? allPossiblePrefixArguments,
|
||||
)
|
||||
const argumentsLeftToMatch = allPossiblePrefixArguments.filter(
|
||||
(i) => !notAppliedSet.has(i) && notAppliedOriginally.has(i),
|
||||
)
|
||||
|
||||
const prefixArgsToDisplay: Array<{
|
||||
@ -94,8 +153,8 @@ export class ArgumentApplication {
|
||||
}> = []
|
||||
|
||||
function insertPlaceholdersUpto(index: number, appTree: AstExtended<Tree>) {
|
||||
while (notAppliedArguments[0] != null && notAppliedArguments[0] < index) {
|
||||
const argIndex = notAppliedArguments.shift()
|
||||
while (placeholdersToInsert[0] != null && placeholdersToInsert[0] < index) {
|
||||
const argIndex = placeholdersToInsert.shift()
|
||||
const argInfo = tryGetIndex(knownArguments, argIndex)
|
||||
if (argIndex != null && argInfo != null)
|
||||
prefixArgsToDisplay.push({
|
||||
@ -105,7 +164,7 @@ export class ArgumentApplication {
|
||||
}
|
||||
}
|
||||
|
||||
for (const realArg of foundArgs) {
|
||||
for (const realArg of interpreted.args) {
|
||||
if (realArg.argName == null) {
|
||||
const argIndex = argumentsLeftToMatch.shift()
|
||||
if (argIndex != null) insertPlaceholdersUpto(argIndex, realArg.appTree)
|
||||
@ -139,12 +198,12 @@ export class ArgumentApplication {
|
||||
}
|
||||
}
|
||||
|
||||
insertPlaceholdersUpto(Infinity, nextApplication)
|
||||
insertPlaceholdersUpto(Infinity, interpreted.func)
|
||||
|
||||
return prefixArgsToDisplay.reduce(
|
||||
(target: ArgumentApplication | AstExtended<Tree>, toDisplay) =>
|
||||
new ArgumentApplication(toDisplay.appTree, target, undefined, toDisplay.argument),
|
||||
nextApplication,
|
||||
interpreted.func,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -156,13 +215,28 @@ const unknownArgInfoNamed = (name: string) => ({
|
||||
hasDefault: false,
|
||||
})
|
||||
|
||||
function getAccessOprSubject(app: AstExtended): AstExtended<Tree> | undefined {
|
||||
if (
|
||||
app.isTree([Tree.Type.OprApp]) &&
|
||||
isAccessOperator(app.tryMap((t) => (t.opr.ok ? t.opr.value : undefined)))
|
||||
) {
|
||||
return app.tryMap((t) => t.lhs)
|
||||
}
|
||||
}
|
||||
|
||||
function isAccessOperator(opr: AstExtended<Token.Operator> | undefined): boolean {
|
||||
return opr != null && opr.repr() === '.'
|
||||
}
|
||||
|
||||
declare const ArgumentApplicationKey: unique symbol
|
||||
declare const ArgumentPlaceholderKey: unique symbol
|
||||
declare const ArgumentAstKey: unique symbol
|
||||
declare const ForcePortKey: unique symbol
|
||||
declare module '@/providers/widgetRegistry' {
|
||||
export interface WidgetInputTypes {
|
||||
[ArgumentApplicationKey]: ArgumentApplication
|
||||
[ArgumentPlaceholderKey]: ArgumentPlaceholder
|
||||
[ArgumentAstKey]: ArgumentAst
|
||||
[ForcePortKey]: ForcePort
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import type { ObservableV2 } from 'lib0/observable'
|
||||
import {
|
||||
computed,
|
||||
onScopeDispose,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
type Ref,
|
||||
type WatchSource,
|
||||
} from 'vue'
|
||||
import { ReactiveDb } from './database/reactiveDb'
|
||||
|
||||
export function isClick(e: MouseEvent | PointerEvent) {
|
||||
if (e instanceof PointerEvent) return e.pointerId !== -1
|
||||
|
@ -94,15 +94,6 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
() => `translate(${translate.value.x * scale.value}px, ${translate.value.y * scale.value}px)`,
|
||||
)
|
||||
|
||||
useEvent(
|
||||
window,
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
let isPointerDown = false
|
||||
let scrolledThisFrame = false
|
||||
const eventMousePos = ref<Vec2 | null>(null)
|
||||
@ -179,6 +170,9 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
center.value = center.value.addScaled(delta, 1 / scale.value)
|
||||
}
|
||||
},
|
||||
contextmenu(e: Event) {
|
||||
e.preventDefault()
|
||||
},
|
||||
},
|
||||
translate,
|
||||
scale,
|
||||
|
Loading…
Reference in New Issue
Block a user