Self-arrows: Distinguish self arguments (#9116)

Distinguish self arguments; render edges to them as arrows, and use icons for their widgets.
This commit is contained in:
Kaz Wesley 2024-02-21 08:40:08 -08:00 committed by GitHub
parent 2a42388905
commit fdec1d0671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 189 additions and 42 deletions

View File

@ -431,7 +431,14 @@ const backwardEdgeArrowTransform = computed<string | undefined>(() => {
return svgTranslate(source.center().add(points[1]))
})
const targetIsSelfArgument = computed(() => false)
const targetIsSelfArgument = computed(() => {
if (!targetExpr.value) return
const nodeId = graph.getPortNodeId(targetExpr.value)
if (!nodeId) return
const primarySubject = graph.db.nodeIdToNode.get(nodeId)?.primarySubject
if (!primarySubject) return
return targetExpr.value === primarySubject
})
const selfArgumentArrowHeight = 9
const selfArgumentArrowYOffset = 0

View File

@ -4,7 +4,6 @@ import CircularMenu from '@/components/CircularMenu.vue'
import GraphNodeError from '@/components/GraphEditor/GraphNodeMessage.vue'
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
import NodeWidgetTree from '@/components/GraphEditor/NodeWidgetTree.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useApproach } from '@/composables/animation'
import { useDoubleClick } from '@/composables/doubleClick'
import { usePointer, useResizeObserver } from '@/composables/events'
@ -26,8 +25,6 @@ import { computed, onUnmounted, ref, watch, watchEffect } from 'vue'
const MAXIMUM_CLICK_LENGTH_MS = 300
const MAXIMUM_CLICK_DISTANCE_SQ = 50
/** The width in pixels that is not the widget tree. This includes the icon, and padding. */
const NODE_EXTRA_WIDTH_PX = 30
const prefixes = Prefixes.FromLines({
enableOutputContext:
@ -73,15 +70,19 @@ const outputPortsSet = computed(() => {
const widthOverridePx = ref<number>()
const nodeId = computed(() => asNodeId(props.node.rootSpan.id))
const externalId = computed(() => props.node.rootSpan.externalId)
const potentialSelfArgumentId = computed(() => props.node.primarySubject)
const connectedSelfArgumentId = computed(() =>
props.node.primarySubject && graph.isConnectedTarget(props.node.primarySubject)
? props.node.primarySubject
: undefined,
)
onUnmounted(() => graph.unregisterNodeRect(nodeId.value))
const rootNode = ref<HTMLElement>()
const contentNode = ref<HTMLElement>()
const nodeSize = useResizeObserver(rootNode)
const baseNodeSize = computed(
() => new Vec2((contentNode.value?.scrollWidth ?? 0) + NODE_EXTRA_WIDTH_PX, nodeSize.value.y),
)
const baseNodeSize = computed(() => new Vec2(contentNode.value?.scrollWidth ?? 0, nodeSize.value.y))
/// Menu can be full, partial or off
enum MenuState {
@ -367,7 +368,7 @@ function openFullMenu() {
transform,
width:
widthOverridePx != null && isVisualizationVisible
? `${Math.max(widthOverridePx, (contentNode?.scrollWidth ?? 0) + NODE_EXTRA_WIDTH_PX)}px`
? `${Math.max(widthOverridePx, contentNode?.scrollWidth ?? 0)}px`
: undefined,
'--node-group-color': color,
}"
@ -412,15 +413,15 @@ function openFullMenu() {
@update:id="emit('update:visualizationId', $event)"
@update:visible="emit('update:visualizationVisible', $event)"
/>
<div class="node" @pointerdown="handleNodeClick" v-on="dragPointer.events">
<SvgIcon
class="icon grab-handle"
:name="icon"
@pointerdown.right.stop="openFullMenu"
></SvgIcon>
<div ref="contentNode" class="widget-tree">
<NodeWidgetTree :ast="displayedExpression" :nodeId="nodeId" />
</div>
<div ref="contentNode" class="node" @pointerdown="handleNodeClick" v-on="dragPointer.events">
<NodeWidgetTree
:ast="displayedExpression"
:nodeId="nodeId"
:icon="icon"
:connectedSelfArgumentId="connectedSelfArgumentId"
:potentialSelfArgumentId="potentialSelfArgumentId"
@openFullMenu="openFullMenu"
/>
</div>
<GraphNodeError v-if="error" class="message" :message="error" type="error" />
<GraphNodeError
@ -638,11 +639,6 @@ function openFullMenu() {
gap: 4px;
}
.grab-handle {
color: white;
margin: 0 4px;
}
.CircularMenu {
z-index: 25;
}

View File

@ -1,13 +1,24 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useTransitioning } from '@/composables/animation'
import { WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
import { provideWidgetTree } from '@/providers/widgetTree'
import { useGraphStore, type NodeId } from '@/stores/graph'
import { Ast } from '@/util/ast'
import type { Icon } from '@/util/iconName'
import { computed, toRef } from 'vue'
const props = defineProps<{ ast: Ast.Ast; nodeId: NodeId }>()
const props = defineProps<{
ast: Ast.Ast
nodeId: NodeId
icon: Icon
connectedSelfArgumentId: Ast.AstId | undefined
potentialSelfArgumentId: Ast.AstId | undefined
}>()
const emit = defineEmits<{
openFullMenu: []
}>()
const graph = useGraphStore()
const rootPort = computed(() => {
const input = WidgetInput.FromAst(props.ast)
@ -52,11 +63,28 @@ function handleWidgetUpdates(update: WidgetUpdate) {
}
const layoutTransitions = useTransitioning(observedLayoutTransitions)
provideWidgetTree(toRef(props, 'ast'), toRef(props, 'nodeId'), layoutTransitions.active)
provideWidgetTree(
toRef(props, 'ast'),
toRef(props, 'nodeId'),
toRef(props, 'icon'),
toRef(props, 'connectedSelfArgumentId'),
toRef(props, 'potentialSelfArgumentId'),
layoutTransitions.active,
() => {
emit('openFullMenu')
},
)
</script>
<template>
<div class="NodeWidgetTree" spellcheck="false" v-on="layoutTransitions.events">
<!-- Display an icon for the node if no widget in the tree provides one. -->
<SvgIcon
v-if="!props.connectedSelfArgumentId"
class="icon grab-handle"
:name="props.icon"
@pointerdown.right.stop="emit('openFullMenu')"
/>
<NodeWidget :input="rootPort" @update="handleWidgetUpdates" />
</div>
</template>
@ -64,7 +92,6 @@ provideWidgetTree(toRef(props, 'ast'), toRef(props, 'nodeId'), layoutTransitions
<style scoped>
.NodeWidgetTree {
color: white;
margin-left: 4px;
outline: none;
height: 24px;
@ -83,4 +110,13 @@ provideWidgetTree(toRef(props, 'ast'), toRef(props, 'nodeId'), layoutTransitions
.GraphEditor.draggingEdge .NodeWidgetTree {
transition: margin 0.2s ease;
}
.icon {
margin-right: 4px;
}
.grab-handle {
color: white;
margin: 0 4px;
}
</style>

View File

@ -1,13 +1,26 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import { Ast } from '@/util/ast'
import { computed } from 'vue'
const props = defineProps(widgetProps(widgetDefinition))
const tree = injectWidgetTree()
const spanClass = computed(() => props.input.value.typeName())
const children = computed(() => [...props.input.value.children()])
const children = computed(() => {
if (
props.input.value instanceof Ast.PropertyAccess &&
tree.connectedSelfArgumentId &&
props.input.value.lhs?.id === tree.connectedSelfArgumentId
) {
// When a self argument is rendered as an icon, omit the property access operator.
return [props.input.value.lhs, props.input.value.rhs]
} else {
return [...props.input.value.children()]
}
})
function transformChild(child: Ast.Ast | Ast.Token) {
const childInput = WidgetInput.FromAst(child)

View File

@ -10,7 +10,7 @@ import { injectWidgetTree } from '@/providers/widgetTree'
import { PortViewInstance, useGraphStore } from '@/stores/graph'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import type { AstId, TokenId } from '@/util/ast/abstract'
import type { TokenId } from '@/util/ast/abstract'
import { ArgumentInfoKey } from '@/util/callTree'
import { Rect } from '@/util/data/rect'
import { asNot } from '@/util/data/types.ts'
@ -39,9 +39,7 @@ const selection = injectGraphSelection(true)
const isHovered = computed(() => selection?.hoveredPort === props.input.portId)
const hasConnection = computed(
() => graph.db.connections.reverseLookup(portId.value as AstId).size > 0,
)
const hasConnection = computed(() => graph.isConnectedTarget(portId.value))
const isCurrentEdgeHoverTarget = computed(
() => isHovered.value && graph.unconnectedEdge != null && selection?.hoveredPort === portId.value,
)
@ -50,7 +48,17 @@ const isCurrentDisconectedEdgeTarget = computed(
graph.unconnectedEdge?.disconnectedEdgeTarget === portId.value &&
graph.unconnectedEdge?.target !== portId.value,
)
const connected = computed(() => hasConnection.value || isCurrentEdgeHoverTarget.value)
const isSelfArgument = computed(
() =>
props.input.value instanceof Ast.Ast && props.input.value.id === tree.connectedSelfArgumentId,
)
const isPotentialSelfArgument = computed(
() =>
props.input.value instanceof Ast.Ast && props.input.value.id === tree.potentialSelfArgumentId,
)
const connected = computed(
() => (!isSelfArgument.value && hasConnection.value) || isCurrentEdgeHoverTarget.value,
)
const isTarget = computed(
() =>
(hasConnection.value && !isCurrentDisconectedEdgeTarget.value) ||
@ -156,6 +164,8 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
:class="{
connected,
isTarget,
isSelfArgument,
isPotentialSelfArgument,
'r-24': connected,
newToConnect: !hasConnection && isCurrentEdgeHoverTarget,
primary: props.nesting < 2,
@ -225,7 +235,7 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
}
}
.WidgetPort.isTarget:after {
.WidgetPort.isTarget:not(.isPotentialSelfArgument):after {
content: '';
position: absolute;
top: -4px;
@ -236,4 +246,8 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
background-color: var(--node-color-port);
z-index: -1;
}
.isSelfArgument {
margin-right: 2px;
}
</style>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { injectWidgetTree } from '@/providers/widgetTree'
import { computed } from 'vue'
const _props = defineProps(widgetProps(widgetDefinition))
const tree = injectWidgetTree()
const icon = computed(() => tree.icon)
</script>
<script lang="ts">
export const widgetDefinition = defineWidget(WidgetInput.isAst, {
priority: 1,
score: (props, _db) =>
props.input.value.id === injectWidgetTree().connectedSelfArgumentId
? Score.Perfect
: Score.Mismatch,
})
</script>
<template>
<SvgIcon class="icon" :name="icon" @pointerdown.right.stop="tree.emitOpenFullMenu()" />
</template>
<style scoped>
.icon {
margin: 0 4px;
}
</style>

View File

@ -2,14 +2,32 @@ import { createContextStore } from '@/providers'
import { useGraphStore } from '@/stores/graph'
import { type NodeId } from '@/stores/graph/graphDatabase'
import { Ast } from '@/util/ast'
import type { Icon } from '@/util/iconName'
import { computed, proxyRefs, type Ref } from 'vue'
export { injectFn as injectWidgetTree, provideFn as provideWidgetTree }
const { provideFn, injectFn } = createContextStore(
'Widget tree',
(astRoot: Ref<Ast.Ast>, nodeId: Ref<NodeId>, hasActiveAnimations: Ref<boolean>) => {
(
astRoot: Ref<Ast.Ast>,
nodeId: Ref<NodeId>,
icon: Ref<Icon>,
connectedSelfArgumentId: Ref<Ast.AstId | undefined>,
potentialSelfArgumentId: Ref<Ast.AstId | undefined>,
hasActiveAnimations: Ref<boolean>,
emitOpenFullMenu: () => void,
) => {
const graph = useGraphStore()
const nodeSpanStart = computed(() => graph.moduleSource.getSpan(astRoot.value.id)![0])
return proxyRefs({ astRoot, nodeId, nodeSpanStart, hasActiveAnimations })
return proxyRefs({
astRoot,
nodeId,
icon,
connectedSelfArgumentId,
potentialSelfArgumentId,
nodeSpanStart,
hasActiveAnimations,
emitOpenFullMenu,
})
},
)

View File

@ -6,7 +6,7 @@ import { Ast, RawAst } from '@/util/ast'
import type { AstId, NodeMetadata } from '@/util/ast/abstract'
import { subtrees } from '@/util/ast/abstract'
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
import { nodeFromAst } from '@/util/ast/node'
import { nodeFromAst, primaryApplicationSubject } from '@/util/ast/node'
import { colorFromString } from '@/util/colors'
import { MappedKeyMap, MappedSet } from '@/util/containers'
import { arrayEquals, tryGetIndex } from '@/util/data/array'
@ -358,7 +358,11 @@ export class GraphDb {
a?.id !== b?.id || (a && subtreeDirty(a.id))
if (differentOrDirty(node.pattern, newNode.pattern)) node.pattern = newNode.pattern
if (node.outerExprId !== newNode.outerExprId) node.outerExprId = newNode.outerExprId
if (differentOrDirty(node.rootSpan, newNode.rootSpan)) node.rootSpan = newNode.rootSpan
if (differentOrDirty(node.rootSpan, newNode.rootSpan)) {
node.rootSpan = newNode.rootSpan
const primarySubject = primaryApplicationSubject(newNode.rootSpan)
if (node.primarySubject !== primarySubject) node.primarySubject = primarySubject
}
}
}
for (const nodeId of this.nodeIdToNode.keys()) {
@ -426,11 +430,10 @@ export class GraphDb {
mockNode(binding: string, id: Ast.AstId, code?: string): Node {
const pattern = Ast.parse(binding)
const node: Node = {
...baseMockNode,
outerExprId: id,
pattern,
rootSpan: Ast.parse(code ?? '0'),
position: Vec2.Zero,
vis: undefined,
}
const bindingId = pattern.id
this.nodeIdToNode.set(asNodeId(id), node)
@ -451,17 +454,24 @@ export interface Node {
rootSpan: Ast.Ast
position: Vec2
vis: Opt<VisualizationMetadata>
/** A child AST in a syntactic position to be a self-argument input to the node. */
primarySubject: Ast.AstId | undefined
}
const baseMockNode = {
position: Vec2.Zero,
vis: undefined,
primarySubject: undefined,
}
/** This should only be used for supplying as initial props when testing.
* Please do {@link GraphDb.mockNode} with a `useGraphStore().db` after mount. */
export function mockNode(exprId?: Ast.AstId): Node {
return {
...baseMockNode,
outerExprId: exprId ?? (random.uuidv4() as Ast.AstId),
pattern: undefined,
rootSpan: Ast.parse('0'),
position: Vec2.Zero,
vis: undefined,
}
}

View File

@ -551,6 +551,10 @@ export const useGraphStore = defineStore('graph', () => {
}
}
function isConnectedTarget(portId: PortId): boolean {
return db.connections.reverseLookup(portId as AstId).size > 0
}
return {
transact,
db: markRaw(db),
@ -593,6 +597,7 @@ export const useGraphStore = defineStore('graph', () => {
edit,
viewModule,
addMissingImports,
isConnectedTarget,
}
})

View File

@ -5,15 +5,32 @@ import { Vec2 } from '@/util/data/vec2'
export function nodeFromAst(ast: Ast.Ast): Node | undefined {
const nodeCode = ast instanceof Ast.Documented ? ast.expression : ast
if (!nodeCode) return
const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined
const rootSpan = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode
return {
outerExprId: ast.id,
pattern: nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined,
rootSpan: nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode,
pattern,
rootSpan,
position: Vec2.Zero,
vis: undefined,
primarySubject: primaryApplicationSubject(rootSpan),
}
}
/** Given a node root, find a child AST that is the root of the access chain that is the subject of the primary
* application.
*/
export function primaryApplicationSubject(ast: Ast.Ast): Ast.AstId | undefined {
// Descend into LHS of any sequence of applications.
while (ast instanceof Ast.App) ast = ast.function
// Require a sequence of at least one property access; descend into LHS.
if (!(ast instanceof Ast.PropertyAccess)) return
while (ast instanceof Ast.PropertyAccess && ast.lhs) ast = ast.lhs
// The leftmost element must be an identifier.
if (!(ast instanceof Ast.Ident)) return
return ast.id
}
if (import.meta.vitest) {
const { test, expect } = await import('vitest')
const { initializeFFI } = await import('shared/ast/ffi')