mirror of
https://github.com/enso-org/enso.git
synced 2024-11-27 05:23:48 +03:00
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:
parent
2a42388905
commit
fdec1d0671
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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')
|
||||
|
Loading…
Reference in New Issue
Block a user