Skip to content

Commit

Permalink
[GUI2] Widget update handlers (#8545)
Browse files Browse the repository at this point in the history
Fixes #8258 #8267

Added a generic way of setting a new expression value from within a widget, and ability to intercept those update calls from the child widgets. Used that to implement function argument assignment and clearing.

https://github.com/enso-org/enso/assets/919491/513b823b-eb2c-45d8-88ac-4971ba061c59
  • Loading branch information
Frizi authored Dec 22, 2023
1 parent b3de42e commit 56657cc
Show file tree
Hide file tree
Showing 37 changed files with 602 additions and 322 deletions.
5 changes: 4 additions & 1 deletion app/gui2/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ export default defineConfig({
// },
// ],
webServer: {
command: 'E2E=true vite build && vite preview',
env: {
E2E: 'true',
},
command: 'vite build && vite preview',
port: 4173,
// We use our special, mocked version of server, thus do not want to re-use user's one.
reuseExistingServer: false,
Expand Down
2 changes: 1 addition & 1 deletion app/gui2/shared/languageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export class LanguageServer extends ObservableV2<Notifications> {

/** [Documentation](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#textopenfile) */
openTextFile(path: Path): Promise<response.OpenTextFile> {
return this.request('text/openFile', { path })
return this.request<response.OpenTextFile>('text/openFile', { path })
}

/** [Documentation](https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-language-server.md#textclosefile) */
Expand Down
2 changes: 1 addition & 1 deletion app/gui2/src/components/ComponentBrowser.vue
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ const previewDataSource: ComputedRef<VisualizationDataSource | undefined> = comp
return {
type: 'expression',
expression: previewedExpression.value,
contextId: body.astId,
contextId: body.exprId,
}
})
Expand Down
10 changes: 6 additions & 4 deletions app/gui2/src/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -551,15 +551,16 @@ function handleEdgeDrop(source: ExprId, position: Vec2) {
toastClassName="text-sm leading-170 bg-frame-selected rounded-2xl backdrop-blur-3xl"
transition="Vue-Toastification__bounce"
/>
<svg :viewBox="graphNavigator.viewBox">
<GraphEdges @createNodeFromEdge="handleEdgeDrop" />
</svg>
<div :style="{ transform: graphNavigator.transform }" class="htmlLayer">
<GraphNodes
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"
@nodeDoubleClick="(id) => stackNavigator.enterNode(id)"
/>
</div>
<svg :viewBox="graphNavigator.viewBox" class="svgBackdropLayer">
<GraphEdges @createNodeFromEdge="handleEdgeDrop" />
</svg>

<ComponentBrowser
v-if="componentBrowserVisible"
ref="componentBrowser"
Expand Down Expand Up @@ -601,10 +602,11 @@ function handleEdgeDrop(source: ExprId, position: Vec2) {
--node-color-no-type: #596b81;
}
svg {
.svgBackdropLayer {
position: absolute;
top: 0;
left: 0;
z-index: -1;
}
.htmlLayer {
Expand Down
21 changes: 10 additions & 11 deletions app/gui2/src/components/GraphEditor/GraphEdge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const sourceNode = computed(() => {
// When the source is not set (i.e. edge is dragged), use the currently hovered over expression
// as the source, as long as it is not from the same node as the target.
if (selection?.hoveredNode != null) {
const rawTargetNode = graph.db.getExpressionNodeId(props.edge.target)
const rawTargetNode = props.edge.target && graph.getPortNodeId(props.edge.target)
if (selection.hoveredNode != rawTargetNode) return selection.hoveredNode
}
}
Expand All @@ -45,30 +45,29 @@ const targetExpr = computed(() => {
})
const targetNode = computed(
() => targetExpr.value && graph.db.getExpressionNodeId(targetExpr.value),
() => targetExpr.value && (graph.getPortNodeId(targetExpr.value) ?? selection?.hoveredNode),
)
const targetNodeRect = computed(() => targetNode.value && graph.nodeRects.get(targetNode.value))
const targetRect = computed<Rect | null>(() => {
const targetRect = computed<Rect | undefined>(() => {
const expr = targetExpr.value
if (expr != null) {
if (targetNode.value == null) return null
const targetRectRelative = graph.exprRects.get(expr)
if (targetRectRelative == null || targetNodeRect.value == null) return null
if (expr != null && targetNode.value != null && targetNodeRect.value != null) {
const targetRectRelative = graph.getPortRelativeRect(expr)
if (targetRectRelative == null) return
return targetRectRelative.offsetBy(targetNodeRect.value.pos)
} else if (navigator?.sceneMousePos != null) {
return new Rect(navigator.sceneMousePos, Vec2.Zero)
} else {
return null
return undefined
}
})
const sourceRect = computed<Rect | null>(() => {
const sourceRect = computed<Rect | undefined>(() => {
if (sourceNode.value != null) {
return graph.nodeRects.get(sourceNode.value) ?? null
return graph.nodeRects.get(sourceNode.value)
} else if (navigator?.sceneMousePos != null) {
return new Rect(navigator.sceneMousePos, Vec2.Zero)
} else {
return null
return undefined
}
})
Expand Down
34 changes: 26 additions & 8 deletions app/gui2/src/components/GraphEditor/GraphEdges.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import GraphEdge from '@/components/GraphEditor/GraphEdge.vue'
import type { GraphNavigator } from '@/providers/graphNavigator'
import { injectGraphSelection } from '@/providers/graphSelection'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import type { PortId } from '@/providers/portInfo'
import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
import { Vec2 } from '@/util/data/vec2'
import type { ExprId } from 'shared/yjsModel'
import { isUuid, type ExprId } from 'shared/yjsModel.ts'
const graph = useGraphStore()
const selection = injectGraphSelection(true)
Expand All @@ -27,7 +29,7 @@ const editingEdge: Interaction = {
if (graph.unconnectedEdge == null) return false
const source = graph.unconnectedEdge.source ?? selection?.hoveredNode
const target = graph.unconnectedEdge.target ?? selection?.hoveredPort
const targetNode = target && graph.db.getExpressionNodeId(target)
const targetNode = target && graph.getPortNodeId(target)
graph.transact(() => {
if (source != null && source != targetNode) {
if (target == null) {
Expand All @@ -43,18 +45,34 @@ const editingEdge: Interaction = {
return true
},
}
interaction.setWhen(() => graph.unconnectedEdge != null, editingEdge)
function disconnectEdge(target: ExprId) {
graph.setExpressionContent(target, '_')
function disconnectEdge(target: PortId) {
if (!graph.updatePortValue(target, undefined)) {
const targetStr: string = target
if (isUuid(targetStr)) {
console.warn(`Failed to disconnect edge from port ${target}, falling back to direct edit.`)
graph.setExpressionContent(targetStr as ExprId, '_')
} else {
console.error(`Failed to disconnect edge from port ${target}, no fallback possible.`)
}
}
}
function createEdge(source: ExprId, target: ExprId) {
function createEdge(source: ExprId, target: PortId) {
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)
// TODO: Use alias analysis to ensure declarations are in a dependency order.
const identAst = Ast.parse(ident)
if (!graph.updatePortValue(target, identAst)) {
const targetStr: string = target
if (isUuid(targetStr)) {
console.warn(`Failed to connect edge to port ${target}, falling back to direct edit.`)
graph.setExpressionContent(targetStr as ExprId, ident)
} else {
console.error(`Failed to connect edge to port ${target}, no fallback possible.`)
}
}
}
</script>

Expand Down
6 changes: 3 additions & 3 deletions app/gui2/src/components/GraphEditor/GraphNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { displayedIconOf } from '@/util/getIconName'
import { setIfUndefined } from 'lib0/map'
import { type ExprId, type VisualizationIdentifier } from 'shared/yjsModel'
import type { ExprId, VisualizationIdentifier } from 'shared/yjsModel'
import { computed, ref, watch, watchEffect } from 'vue'
const MAXIMUM_CLICK_LENGTH_MS = 300
Expand Down Expand Up @@ -71,7 +71,7 @@ const outputPortsSet = computed(() => {
})
const widthOverridePx = ref<number>()
const nodeId = computed(() => props.node.rootSpan.astId)
const nodeId = computed(() => props.node.rootSpan.exprId)
const rootNode = ref<HTMLElement>()
const contentNode = ref<HTMLElement>()
Expand Down Expand Up @@ -194,7 +194,7 @@ const isOutputContextOverridden = computed({
disableOutputContext: undefined,
},
)
graph.setNodeContent(props.node.rootSpan.astId, newAst.code())
graph.setNodeContent(props.node.rootSpan.exprId, newAst.code())
},
})
Expand Down
50 changes: 38 additions & 12 deletions app/gui2/src/components/GraphEditor/NodeWidget.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { PortId } from '@/providers/portInfo'
import { injectWidgetRegistry, type WidgetInput } from '@/providers/widgetRegistry'
import type { WidgetConfiguration } from '@/providers/widgetRegistry/configuration'
import { injectWidgetTree } from '@/providers/widgetTree'
Expand All @@ -8,30 +9,31 @@ import {
usageKeyForInput,
} from '@/providers/widgetUsageInfo'
import { Ast } from '@/util/ast'
import type { Opt } from '@/util/data/opt'
import { computed, proxyRefs } from 'vue'
const props = defineProps<{
input: WidgetInput
nest?: boolean
dynamicConfig?: Opt<WidgetConfiguration>
dynamicConfig?: WidgetConfiguration | undefined
/**
* A function that intercepts and handles a value update emitted by this widget. When it returns
* `false`, the update continues to be propagated to the parent widget. When it returns `true`,
* the update is considered handled and is not propagated further.
*/
onUpdate?: UpdateHandler
}>()
defineOptions({
inheritAttrs: false,
})
type UpdateHandler = (value: unknown, origin: PortId) => boolean
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(() =>
!sameInputAsParent.value && props.input instanceof Ast.Ast
? ' '.repeat(props.input.astExtended?.whitespaceLength() ?? 0)
: '',
)
const sameInputParentWidgets = computed(() =>
sameInputAsParent.value ? parentUsageInfo?.previouslyUsed : undefined,
)
Expand All @@ -47,10 +49,25 @@ const selectedWidget = computed(() => {
sameInputParentWidgets.value,
)
})
const updateHandler = computed(() => {
const nextHandler =
parentUsageInfo?.updateHandler ?? (() => console.log('Missing update handler'))
if (props.onUpdate != null) {
const localHandler = props.onUpdate
return (value: unknown, origin: PortId) => {
const handled = localHandler(value, origin)
if (!handled) nextHandler(value, origin)
}
}
return nextHandler
})
provideWidgetUsageInfo(
proxyRefs({
usageKey,
nesting,
updateHandler,
previouslyUsed: computed(() => {
const nextSameNodeWidgets = new Set(sameInputParentWidgets.value)
if (selectedWidget.value != null) {
Expand All @@ -64,24 +81,24 @@ provideWidgetUsageInfo(
}),
}),
)
const spanStart = computed(() => {
if (!(props.input instanceof Ast.Ast)) return undefined
if (props.input.astExtended == null) return undefined
return props.input.astExtended.span()[0] - tree.nodeSpanStart - whitespace.value.length
return props.input.astExtended.span()[0] - tree.nodeSpanStart
})
</script>

<template>
{{ whitespace
}}<component
<component
:is="selectedWidget.default"
v-if="selectedWidget"
ref="rootNode"
:input="props.input"
:config="dynamicConfig"
:nesting="nesting"
:data-span-start="spanStart"
:data-nesting="nesting"
@update="updateHandler"
/>
<span
v-else
Expand All @@ -91,3 +108,12 @@ const spanStart = computed(() => {
>🚫</span
>
</template>

<style scoped>
.whitespace {
color: transparent;
pointer-events: none;
user-select: none;
white-space: pre;
}
</style>
29 changes: 22 additions & 7 deletions app/gui2/src/components/GraphEditor/NodeWidgetTree.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
<script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { useTransitioning } from '@/composables/animation'
import { ForcePort } from '@/providers/portInfo'
import { ForcePort, type PortId } from '@/providers/portInfo'
import { provideWidgetTree } from '@/providers/widgetTree'
import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
import { isUuid } from 'shared/yjsModel'
import { computed, toRef } from 'vue'
const props = defineProps<{ ast: Ast.Ast }>()
const graph = useGraphStore()
const rootPort = computed(() => {
return props.ast instanceof Ast.Ident && !graph.db.isKnownFunctionCall(props.ast.astId)
return props.ast instanceof Ast.Ident && !graph.db.isKnownFunctionCall(props.ast.exprId)
? new ForcePort(props.ast)
: props.ast
})
Expand All @@ -28,13 +29,31 @@ const observedLayoutTransitions = new Set([
'height',
])
function handleWidgetUpdates(value: unknown, origin: PortId) {
// TODO: Implement proper AST-based update.
if (!isUuid(origin)) {
console.error(`[UPDATE ${origin}] Invalid top-level origin. Expected expression ID.`)
} else if (typeof value === 'string') {
graph.setExpressionContent(origin, value)
} else if (value instanceof Ast.Ast) {
graph.setExpressionContent(origin, value.repr())
} else if (value == null) {
graph.setExpressionContent(origin, '_')
} else {
console.error(`[UPDATE ${origin}] Invalid value:`, value)
}
// No matter if its a succes or not, this handler is always considered to have handled the update,
// since it is guaranteed to be the last handler in the chain.
return true
}
const layoutTransitions = useTransitioning(observedLayoutTransitions)
provideWidgetTree(toRef(props, 'ast'), layoutTransitions.active)
</script>

<template>
<div class="NodeWidgetTree" spellcheck="false" v-on="layoutTransitions.events">
<NodeWidget :input="rootPort" />
<NodeWidget :input="rootPort" @update="handleWidgetUpdates" />
</div>
</template>

Expand All @@ -48,10 +67,6 @@ provideWidgetTree(toRef(props, 'ast'), layoutTransitions.active)
display: flex;
align-items: center;
& :deep(span) {
vertical-align: middle;
}
&:has(.WidgetPort.newToConnect) {
margin-left: calc(4px - var(--widget-port-extra-pad));
}
Expand Down
Loading

0 comments on commit 56657cc

Please sign in to comment.