From fdec1d0671faabf72fd06b7a89343a4ecfbbad7d Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Wed, 21 Feb 2024 08:40:08 -0800 Subject: [PATCH] Self-arrows: Distinguish self arguments (#9116) Distinguish self arguments; render edges to them as arrows, and use icons for their widgets. --- .../src/components/GraphEditor/GraphEdge.vue | 9 +++- .../src/components/GraphEditor/GraphNode.vue | 38 ++++++++--------- .../components/GraphEditor/NodeWidgetTree.vue | 42 +++++++++++++++++-- .../GraphEditor/widgets/WidgetHierarchy.vue | 15 ++++++- .../GraphEditor/widgets/WidgetPort.vue | 26 +++++++++--- .../GraphEditor/widgets/WidgetSelfIcon.vue | 31 ++++++++++++++ app/gui2/src/providers/widgetTree.ts | 22 +++++++++- app/gui2/src/stores/graph/graphDatabase.ts | 22 +++++++--- app/gui2/src/stores/graph/index.ts | 5 +++ app/gui2/src/util/ast/node.ts | 21 +++++++++- 10 files changed, 189 insertions(+), 42 deletions(-) create mode 100644 app/gui2/src/components/GraphEditor/widgets/WidgetSelfIcon.vue diff --git a/app/gui2/src/components/GraphEditor/GraphEdge.vue b/app/gui2/src/components/GraphEditor/GraphEdge.vue index c176cb2a62..39ebc0ca44 100644 --- a/app/gui2/src/components/GraphEditor/GraphEdge.vue +++ b/app/gui2/src/components/GraphEditor/GraphEdge.vue @@ -431,7 +431,14 @@ const backwardEdgeArrowTransform = computed(() => { 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 diff --git a/app/gui2/src/components/GraphEditor/GraphNode.vue b/app/gui2/src/components/GraphEditor/GraphNode.vue index 47d238b694..2d81a4de90 100644 --- a/app/gui2/src/components/GraphEditor/GraphNode.vue +++ b/app/gui2/src/components/GraphEditor/GraphNode.vue @@ -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() 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() const contentNode = ref() 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)" /> -
- -
- -
+
+
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') + }, +) @@ -64,7 +92,6 @@ provideWidgetTree(toRef(props, 'ast'), toRef(props, 'nodeId'), layoutTransitions diff --git a/app/gui2/src/components/GraphEditor/widgets/WidgetHierarchy.vue b/app/gui2/src/components/GraphEditor/widgets/WidgetHierarchy.vue index 683df7bbeb..6939121335 100644 --- a/app/gui2/src/components/GraphEditor/widgets/WidgetHierarchy.vue +++ b/app/gui2/src/components/GraphEditor/widgets/WidgetHierarchy.vue @@ -1,13 +1,26 @@ + + + + + + diff --git a/app/gui2/src/providers/widgetTree.ts b/app/gui2/src/providers/widgetTree.ts index b30ec645d7..fccae085a2 100644 --- a/app/gui2/src/providers/widgetTree.ts +++ b/app/gui2/src/providers/widgetTree.ts @@ -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, nodeId: Ref, hasActiveAnimations: Ref) => { + ( + astRoot: Ref, + nodeId: Ref, + icon: Ref, + connectedSelfArgumentId: Ref, + potentialSelfArgumentId: Ref, + hasActiveAnimations: Ref, + 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, + }) }, ) diff --git a/app/gui2/src/stores/graph/graphDatabase.ts b/app/gui2/src/stores/graph/graphDatabase.ts index 29782c1510..339efe4c2e 100644 --- a/app/gui2/src/stores/graph/graphDatabase.ts +++ b/app/gui2/src/stores/graph/graphDatabase.ts @@ -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 + /** 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, } } diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index 3da47174ca..023f19b531 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -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, } }) diff --git a/app/gui2/src/util/ast/node.ts b/app/gui2/src/util/ast/node.ts index 5164f4deb7..63e4ef5998 100644 --- a/app/gui2/src/util/ast/node.ts +++ b/app/gui2/src/util/ast/node.ts @@ -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')